Compare commits

...

4 Commits

Author SHA1 Message Date
Brandon Thomas 8c026a9965 Restyle the release feed's notices and alerts for the new theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:15:38 -04:00
Brandon Thomas 3d92df6423 Rate-limit calls to the ArtCraft API; back off hard on HTTP 429
Every launcher polls the same server, so a FeedThrottle now gates each request:
- at most one a minute once the first load is over;
- up to ten a minute during the first load, front-loaded, with the gaps doubling after each
  failure (the first load ends at the first answer or after ten requests);
- failures back off 2, 4, 8 … minutes (at most an hour), with jitter;
- HTTP 429 waits at least 15 minutes, doubling per 429 in a row up to six hours, never sooner
  than Retry-After, plus up to 50% jitter so a herd of clients doesn't return in step. The
  backoff is saved to cache/feed-throttle.json, so relaunching doesn't skip it.

A check that would have to wait more than ten seconds goes without the feed this time, and the
saved answer stands in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:14:47 -04:00
Brandon Thomas b2abc6cac6 Footer names the services in use; retry the ArtCraft API before GitHub
Footer: "Checked just now · using ArtCraft API", "· using GitHub" or
"· using ArtCraft API and GitHub", from where each release on screen came
from (Latest events now carry their origin), so it follows what's shown.

Retries and fallback:
- The very first poll (nothing ever loaded or saved) tries the ArtCraft
  API once, then GitHub at once, so the window fills
- Every other poll tries the API up to three times (1 s, then 3 s apart)
- Until every app has loaded once, a failing API lets GitHub be asked
  fairly freely (every 5 minutes per app)
- After that, GitHub is asked only for apps the API doesn't list or
  hasn't refreshed in a day, or once the API has seriously failed (three
  checks in a row and an hour without an answer); then hourly per app.
  Until then the API's last answer keeps being shown

Tests: the fallback policy for each stage, and the footer wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:14:47 -04:00
Brandon Thomas 7287ec978e Use ArtCraft's release feed; GitHub only as the fallback
The launcher now asks storyteller-web
(GET https://api.storyteller.ai/v1/craft_apps/release_info), which polls
GitHub for every Craft App on a schedule, instead of polling GitHub itself.

- At launch and every five minutes: one request (10 s timeout) for every
  app's latest release, plus renames and notices
- Per app, GitHub is asked only when the feed can't vouch for it: the app
  isn't listed, its entry is more than 24 hours old, or the feed didn't
  answer. Even then, at most once an hour per app (Refresh asks at once).
  With pre-releases on, GitHub decides (the feed serves stable releases)
- Read leniently (crates/core/src/feed.rs): every field optional, unknown
  fields ignored, wrong types read as missing, one bad entry never spoils
  the rest. Download links must still point at the app's own GitHub
  releases; a moved repo counts only when the feed's repo_renames says so
- The last good answer is saved for an instant first paint and stands in
  (while still fresh) when the feed can't be reached
- Announcements show inline until dismissed; alerts pop up until the user
  presses OK (with "Open link" for web links); app renames show as
  announcements; dismissals persist
- "Check for updates": Automatically (every five minutes) or Only when I
  ask; older saved choices load as Automatically
- ARTCRAFT_LAUNCHER_FEED_URL points at a staging server or a local copy

Tests: the feed parser against hostile input, renames and notices; a
contract test against a response produced by storyteller-web's own code;
the feed-first rule with a counting fake GitHub; the notices UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:14:40 -04:00
15 changed files with 1703 additions and 45 deletions

View File

@ -3,7 +3,7 @@
use std::cell::RefCell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Source};
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, Source};
use artcraft_launcher_core::release::{self, Release};
use artcraft_launcher_core::{Arch, Os, Platform, SUITE, catalog, version};
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
@ -94,7 +94,7 @@ impl WebBackend {
let latest = latest.unwrap_or_else(|e| {
LatestRelease::Failed(if e.is_some() { "GitHub's hourly limit was reached".into() } else { "couldn't reach GitHub".into() })
});
events.borrow_mut().push(Event::Latest { app: app.id.to_string(), latest });
events.borrow_mut().push(Event::Latest { app: app.id.to_string(), latest, from: Origin::GitHub });
ctx.request_repaint();
}
if let Ok(LatestRelease::Found(r)) = latest(catalog::LAUNCHER_REPO, false).await

568
crates/core/src/feed.rs Normal file
View File

@ -0,0 +1,568 @@
//! The release feed from storyteller-web: `GET https://api.storyteller.ai/v1/craft_apps/release_info`.
//!
//! storyteller-web polls GitHub for every Craft App on a schedule and serves the result, so the
//! launcher doesn't have to (GitHub allows 60 unauthenticated requests an hour). GitHub stays the
//! fallback: for apps the feed doesn't list, apps it hasn't fetched within [`MAX_AGE`], or when
//! the feed doesn't answer.
//!
//! The feed is input like any other: every field is optional, unknown fields are ignored, a field
//! of the wrong type reads as missing, one bad entry never spoils the others, and download links
//! must still point at the app's own GitHub releases.
use std::collections::BTreeMap;
use serde::Deserialize;
use serde::de::{DeserializeOwned, Deserializer};
use crate::catalog::CraftApp;
use crate::release::{self, Asset, Release};
use crate::time::parse_rfc3339;
use crate::version;
/// Where the feed lives.
pub const FEED_URL: &str = "https://api.storyteller.ai/v1/craft_apps/release_info";
/// An app's entry is trusted for this long after storyteller-web fetched it.
pub const MAX_AGE: i64 = 24 * 3600;
/// The feed, read leniently.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Feed {
/// By lowercase app name.
pub apps: BTreeMap<String, FeedApp>,
/// When the least recently fetched app was fetched (Unix seconds).
pub last_fetched: Option<i64>,
pub app_renames: Vec<AppRename>,
pub repo_renames: Vec<RepoRename>,
pub announcements: Vec<Notice>,
pub alerts: Vec<Notice>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FeedApp {
pub repo: Option<String>,
/// Present when the repo has a published release. Meaningless unless `fetched_at` is set.
pub release: Option<FeedRelease>,
/// When storyteller-web last fetched this app (Unix seconds).
pub fetched_at: Option<i64>,
}
/// A release as the feed describes it, before it's checked against the app's repo.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FeedRelease {
pub tag: String,
pub name: String,
pub published_at: Option<i64>,
pub html_url: String,
pub prerelease: bool,
pub notes: String,
pub assets: Vec<(String, u64, String, Option<String>)>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AppRename {
pub old_name: String,
pub old_name_lower: String,
pub new_name: String,
pub new_name_lower: String,
pub description: String,
pub renamed_at: Option<i64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RepoRename {
pub old_repo: String,
pub new_repo: String,
pub renamed_at: Option<i64>,
}
/// An announcement (shown inline, dismissible) or an alert (a popup the user acknowledges).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Notice {
/// Stable, so a dismissal sticks across launches and refreshes.
pub id: String,
pub title: String,
pub body: String,
/// Only `https://` / `http://` links survive parsing.
pub link: Option<String>,
pub created_at: Option<i64>,
}
/// What the feed says about one app, once checked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FeedAnswer {
/// Fresh: the app's latest release, or `None` when it has none.
Fresh(Option<Release>),
/// Not listed, never fetched, or older than [`MAX_AGE`]: ask GitHub.
AskGitHub,
}
const MAX_NOTICES: usize = 20;
const MAX_TEXT: usize = 4000;
impl Feed {
/// Reads the feed. `None` only when the answer isn't JSON (or isn't an object) at all.
pub fn parse(json: &str) -> Option<Self> {
let raw: RawResponse = serde_json::from_str(json).ok()?;
let mut feed = Feed {
apps: raw.release_info.apps.into_iter().map(|(k, v)| (k.to_lowercase(), v.into_app())).collect(),
last_fetched: raw.release_info.last_fetched.as_deref().and_then(parse_rfc3339),
app_renames: raw.app_renames.into_iter().filter_map(RawAppRename::into_rename).collect(),
repo_renames: raw.repo_renames.into_iter().filter_map(RawRepoRename::into_rename).collect(),
announcements: raw.important_notices.announcements.into_iter().filter_map(|n| n.into_notice("announcement")).take(MAX_NOTICES).collect(),
alerts: raw.important_notices.alerts.into_iter().filter_map(|n| n.into_notice("alert")).take(MAX_NOTICES).collect(),
};
// App renames are news too: show them with the announcements.
for r in &feed.app_renames {
feed.announcements.push(Notice {
id: format!("rename:{}:{}", r.old_name_lower, r.new_name_lower),
title: format!("{} is now {}", r.old_name, r.new_name),
body: r.description.clone(),
link: None,
created_at: r.renamed_at,
});
}
Some(feed)
}
/// The repo to ask GitHub about for `app`, following the feed's repo renames.
pub fn repo_for(&self, app: &CraftApp) -> String {
let mut repo = app.repo.to_string();
// Follow a chain of moves (a → b → c), without looping forever.
for _ in 0..8 {
match self.repo_renames.iter().find(|r| r.old_repo.eq_ignore_ascii_case(&repo)) {
Some(r) if !r.new_repo.eq_ignore_ascii_case(&repo) => repo = r.new_repo.clone(),
_ => break,
}
}
repo
}
/// What the feed says about `app` at `now`.
pub fn answer(&self, app: &CraftApp, now: i64) -> FeedAnswer {
let Some(entry) = self.apps.get(app.id) else { return FeedAnswer::AskGitHub };
let Some(fetched_at) = entry.fetched_at else { return FeedAnswer::AskGitHub };
// A timestamp from the future (clock skew) counts as just now; one too old doesn't count.
if now.saturating_sub(fetched_at) > MAX_AGE {
return FeedAnswer::AskGitHub;
}
// The feed's repo must be the app's, or one the feed says it moved to.
let repo = self.repo_for(app);
if let Some(feed_repo) = &entry.repo
&& !feed_repo.eq_ignore_ascii_case(&repo)
{
return FeedAnswer::AskGitHub;
}
match &entry.release {
None => FeedAnswer::Fresh(None),
Some(r) => match r.to_release(&repo) {
Some(release) => FeedAnswer::Fresh(Some(release)),
None => FeedAnswer::AskGitHub,
},
}
}
}
impl FeedRelease {
/// The release with only the downloads that belong to `repo`. `None` when it doesn't hold up
/// (no tag, or not a version).
fn to_release(&self, repo: &str) -> Option<Release> {
let version = version::parse(&self.tag)?;
let prefix = release::download_prefix(repo);
let releases_page = format!("https://github.com/{repo}/releases/");
let assets = self
.assets
.iter()
.filter(|(name, _, url, _)| release::valid_file_name(name) && url.starts_with(&prefix) && !url.contains(['?', '#', '\\']))
.map(|(name, size, url, sha)| Asset {
name: name.clone(),
size: *size,
url: url.clone(),
sha256: sha.clone().filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit())).map(|h| h.to_ascii_lowercase()),
})
.collect();
Some(Release {
version: Some(version),
name: if self.name.is_empty() { self.tag.clone() } else { truncate(&self.name, 120) },
tag: self.tag.clone(),
published_at: self.published_at,
html_url: if self.html_url.starts_with(&releases_page) { self.html_url.clone() } else { format!("{releases_page}tag/{}", self.tag) },
notes: truncate(&self.notes, 64 * 1024),
prerelease: self.prerelease,
assets,
})
}
}
// ---- lenient wire format ----------------------------------------------------------------------
/// Reads `T`, or its default when the value is missing, `null` or the wrong shape.
fn lenient<'de, D: Deserializer<'de>, T: DeserializeOwned + Default>(d: D) -> Result<T, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(T::deserialize(value).unwrap_or_default())
}
/// A list where entries of the wrong shape are dropped rather than spoiling the rest.
fn lenient_vec<'de, D: Deserializer<'de>, T: DeserializeOwned>(d: D) -> Result<Vec<T>, D::Error> {
let value = serde_json::Value::deserialize(d)?;
let items = match value {
serde_json::Value::Array(items) => items,
_ => return Ok(Vec::new()),
};
Ok(items.into_iter().filter_map(|v| T::deserialize(v).ok()).collect())
}
/// A map where entries of the wrong shape are dropped.
fn lenient_map<'de, D: Deserializer<'de>, T: DeserializeOwned>(d: D) -> Result<BTreeMap<String, T>, D::Error> {
let value = serde_json::Value::deserialize(d)?;
let entries = match value {
serde_json::Value::Object(entries) => entries,
_ => return Ok(BTreeMap::new()),
};
Ok(entries.into_iter().filter_map(|(k, v)| T::deserialize(v).ok().map(|t| (k, t))).collect())
}
#[derive(Deserialize, Default)]
struct RawResponse {
#[serde(default, deserialize_with = "lenient")]
release_info: RawReleaseInfo,
#[serde(default, deserialize_with = "lenient_vec")]
app_renames: Vec<RawAppRename>,
#[serde(default, deserialize_with = "lenient_vec")]
repo_renames: Vec<RawRepoRename>,
#[serde(default, deserialize_with = "lenient")]
important_notices: RawNotices,
}
#[derive(Deserialize, Default)]
struct RawReleaseInfo {
#[serde(default, deserialize_with = "lenient_map")]
apps: BTreeMap<String, RawApp>,
#[serde(default, deserialize_with = "lenient")]
last_fetched: Option<String>,
}
#[derive(Deserialize, Default)]
struct RawApp {
#[serde(default, deserialize_with = "lenient")]
repo: Option<String>,
#[serde(default, deserialize_with = "lenient")]
latest_release: Option<RawRelease>,
#[serde(default, deserialize_with = "lenient")]
app_last_fetched: Option<String>,
}
impl RawApp {
fn into_app(self) -> FeedApp {
FeedApp {
repo: self.repo.filter(|r| r.split('/').count() == 2),
release: self.latest_release.and_then(RawRelease::into_release),
fetched_at: self.app_last_fetched.as_deref().and_then(parse_rfc3339),
}
}
}
#[derive(Deserialize, Default)]
struct RawRelease {
#[serde(default, deserialize_with = "lenient")]
tag: Option<String>,
#[serde(default, deserialize_with = "lenient")]
name: Option<String>,
#[serde(default, deserialize_with = "lenient")]
published_at: Option<String>,
#[serde(default, deserialize_with = "lenient")]
html_url: Option<String>,
#[serde(default, deserialize_with = "lenient")]
prerelease: bool,
#[serde(default, deserialize_with = "lenient")]
notes_markdown: Option<String>,
#[serde(default, deserialize_with = "lenient_vec")]
assets: Vec<RawAsset>,
}
impl RawRelease {
fn into_release(self) -> Option<FeedRelease> {
let tag = self.tag.filter(|t| !t.is_empty() && t.len() <= 64)?;
Some(FeedRelease {
tag,
name: self.name.unwrap_or_default(),
published_at: self.published_at.as_deref().and_then(parse_rfc3339),
html_url: self.html_url.unwrap_or_default(),
prerelease: self.prerelease,
notes: self.notes_markdown.unwrap_or_default(),
assets: self.assets.into_iter().filter_map(|a| Some((a.name?, a.size_bytes, a.download_url?, a.sha256))).take(300).collect(),
})
}
}
#[derive(Deserialize, Default)]
struct RawAsset {
#[serde(default, deserialize_with = "lenient")]
name: Option<String>,
#[serde(default, deserialize_with = "lenient")]
size_bytes: u64,
#[serde(default, deserialize_with = "lenient")]
download_url: Option<String>,
#[serde(default, deserialize_with = "lenient")]
sha256: Option<String>,
}
#[derive(Deserialize, Default)]
struct RawAppRename {
#[serde(default, deserialize_with = "lenient")]
old_name: String,
#[serde(default, deserialize_with = "lenient")]
old_name_lower: String,
#[serde(default, deserialize_with = "lenient")]
new_name: String,
#[serde(default, deserialize_with = "lenient")]
new_name_lower: String,
#[serde(default, deserialize_with = "lenient")]
description: String,
#[serde(default, deserialize_with = "lenient")]
renamed_at: Option<String>,
}
impl RawAppRename {
fn into_rename(self) -> Option<AppRename> {
(!self.old_name_lower.is_empty() && !self.new_name_lower.is_empty()).then(|| AppRename {
old_name: if self.old_name.is_empty() { self.old_name_lower.clone() } else { truncate(&self.old_name, 80) },
new_name: if self.new_name.is_empty() { self.new_name_lower.clone() } else { truncate(&self.new_name, 80) },
old_name_lower: self.old_name_lower.to_lowercase(),
new_name_lower: self.new_name_lower.to_lowercase(),
description: truncate(&self.description, MAX_TEXT),
renamed_at: self.renamed_at.as_deref().and_then(parse_rfc3339),
})
}
}
#[derive(Deserialize, Default)]
struct RawRepoRename {
#[serde(default, deserialize_with = "lenient")]
old_name: String,
#[serde(default, deserialize_with = "lenient")]
new_name: String,
#[serde(default, deserialize_with = "lenient")]
renamed_at: Option<String>,
}
impl RawRepoRename {
fn into_rename(self) -> Option<RepoRename> {
let ok = |r: &str| {
let mut parts = r.split('/');
matches!((parts.next(), parts.next(), parts.next()), (Some(a), Some(b), None) if !a.is_empty() && !b.is_empty()
&& r.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_./".contains(&c)))
};
(ok(&self.old_name) && ok(&self.new_name)).then(|| RepoRename {
old_repo: self.old_name,
new_repo: self.new_name,
renamed_at: self.renamed_at.as_deref().and_then(parse_rfc3339),
})
}
}
#[derive(Deserialize, Default)]
struct RawNotices {
#[serde(default, deserialize_with = "lenient_vec")]
announcements: Vec<RawNotice>,
#[serde(default, deserialize_with = "lenient_vec")]
alerts: Vec<RawNotice>,
}
#[derive(Deserialize, Default)]
struct RawNotice {
#[serde(default, deserialize_with = "lenient")]
title: String,
#[serde(default, deserialize_with = "lenient")]
body: String,
#[serde(default, deserialize_with = "lenient")]
maybe_web_link: Option<String>,
#[serde(default, deserialize_with = "lenient")]
created_at: Option<String>,
}
impl RawNotice {
fn into_notice(self, kind: &str) -> Option<Notice> {
if self.title.trim().is_empty() && self.body.trim().is_empty() {
return None;
}
let link = self
.maybe_web_link
.filter(|l| (l.starts_with("https://") || l.starts_with("http://")) && l.len() <= 2000 && !l.chars().any(char::is_whitespace));
Some(Notice {
id: format!("{kind}:{}:{}", self.created_at.as_deref().unwrap_or(""), self.title.trim()),
title: truncate(self.title.trim(), 200),
body: truncate(self.body.trim(), MAX_TEXT),
link,
created_at: self.created_at.as_deref().and_then(parse_rfc3339),
})
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CraftApp, LAUNCHER};
const NOW: i64 = 1_791_500_000; // 2026-10-09T…
const HASH: &str = "8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1";
/// What storyteller-web serves (its `GetCraftAppsReleaseInfoResponse`), abridged.
fn sample() -> String {
format!(
r#"{{
"success": true,
"release_info": {{
"apps": {{
"photocraft": {{
"name": "PhotoCraft", "repo": "storytold/photocraft",
"latest_release": {{
"tag": "v0.6.0", "version": "0.6.0", "name": "PhotoCraft v0.6.0",
"published_at": "2026-10-09T10:00:00Z",
"html_url": "https://github.com/storytold/photocraft/releases/tag/v0.6.0",
"prerelease": false, "notes_markdown": "Notes",
"assets": [
{{ "name": "photocraft-0.6.0-macos-universal.dmg", "size_bytes": 123,
"download_url": "https://github.com/storytold/photocraft/releases/download/v0.6.0/photocraft-0.6.0-macos-universal.dmg",
"sha256": "{HASH}", "platform": {{ "os": "macos", "arch": "universal", "package_type": "dmg" }} }},
{{ "name": "evil.dmg", "size_bytes": 1, "download_url": "https://evil.example/evil.dmg", "sha256": null }}
]
}},
"app_last_fetched": "2026-10-09T13:00:00Z"
}},
"soundcraft": {{ "name": "SoundCraft", "repo": "storytold/soundcraft", "latest_release": null,
"app_last_fetched": "2026-10-09T13:00:00Z" }},
"filmcraft": {{ "name": "FilmCraft", "repo": "storytold/filmcraft", "latest_release": null, "app_last_fetched": null }},
"vectorcraft": {{ "repo": "storytold/vectorcraft", "app_last_fetched": "2026-10-01T00:00:00Z",
"latest_release": {{ "tag": "v0.7.0" }} }},
"brandnewcraft": {{ "repo": "storytold/brandnewcraft", "app_last_fetched": "2026-10-09T13:00:00Z" }}
}},
"last_fetched": "2026-10-09T13:00:00Z",
"some_future_field": {{ "whatever": [1, 2, 3] }}
}},
"app_renames": [],
"repo_renames": [],
"important_notices": {{ "announcements": [], "alerts": [] }}
}}"#
)
}
fn app(id: &str) -> &'static CraftApp {
CraftApp::by_id(id).unwrap()
}
#[test]
fn uses_fresh_entries_and_falls_back_for_everything_else() {
let feed = Feed::parse(&sample()).unwrap();
// Fresh, with a release: only the download that belongs to the repo survives.
let FeedAnswer::Fresh(Some(r)) = feed.answer(app("photocraft"), NOW) else { panic!() };
assert_eq!(r.version_label(), "0.6.0");
assert_eq!(r.assets.len(), 1);
assert_eq!(r.assets[0].sha256.as_deref(), Some(HASH));
assert_eq!(r.published_at, parse_rfc3339("2026-10-09T10:00:00Z"));
// Fresh, and no release yet.
assert_eq!(feed.answer(app("soundcraft"), NOW), FeedAnswer::Fresh(None));
// Never fetched, more than a day old, or not listed: ask GitHub.
assert_eq!(feed.answer(app("filmcraft"), NOW), FeedAnswer::AskGitHub);
assert_eq!(feed.answer(app("vectorcraft"), NOW), FeedAnswer::AskGitHub);
assert_eq!(feed.answer(app("wordcraft"), NOW), FeedAnswer::AskGitHub);
assert_eq!(feed.answer(&LAUNCHER, NOW), FeedAnswer::AskGitHub);
// An app the launcher doesn't know is simply carried along.
assert!(feed.apps.contains_key("brandnewcraft"));
}
#[test]
fn survives_anything_the_server_might_send() {
for junk in ["", "null", "[]", "42", "<html>502 Bad Gateway</html>", "{\"success\":tru"] {
let feed = Feed::parse(junk);
if let Some(feed) = feed {
assert!(feed.apps.is_empty(), "{junk}");
}
}
// Every field the wrong type: nothing panics, nothing is trusted.
let weird = r#"{"release_info": {"apps": {"photocraft": {"repo": 7, "latest_release": "yes",
"app_last_fetched": ["no"]}, "pdfcraft": 5}, "last_fetched": {}},
"app_renames": "none", "repo_renames": [1, {"old_name": "a/b"}],
"important_notices": {"announcements": [null, {"title": 3}], "alerts": "x"}}"#;
let feed = Feed::parse(weird).unwrap();
assert_eq!(feed.answer(app("photocraft"), NOW), FeedAnswer::AskGitHub);
assert!(feed.repo_renames.is_empty() && feed.announcements.is_empty() && feed.alerts.is_empty());
// An empty object is a feed that knows nothing.
assert_eq!(Feed::parse("{}").unwrap(), Feed::default());
}
#[test]
fn follows_repo_renames_but_never_trusts_a_repo_it_was_not_told_about() {
let moved = sample()
.replace(
"\"repo_renames\": []",
r#""repo_renames": [{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null}]"#,
)
.replace("\"name\": \"PhotoCraft\", \"repo\": \"storytold/photocraft\"", "\"name\": \"PhotoCraft\", \"repo\": \"art/photocraft\"")
.replace("github.com/storytold/photocraft/releases", "github.com/art/photocraft/releases");
let feed = Feed::parse(&moved).unwrap();
assert_eq!(feed.repo_for(app("photocraft")), "art/photocraft");
let FeedAnswer::Fresh(Some(r)) = feed.answer(app("photocraft"), NOW) else { panic!() };
assert!(r.assets[0].url.starts_with("https://github.com/art/photocraft/releases/download/"));
// The same entry without the rename: the repo doesn't match, so GitHub decides.
let unannounced = moved.replace(r#"{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null}"#, "");
let feed = Feed::parse(&unannounced).unwrap();
assert_eq!(feed.answer(app("photocraft"), NOW), FeedAnswer::AskGitHub);
}
/// Contract check against what storyteller-web really serves: point `CRAFT_APPS_FEED_SAMPLE`
/// at a response it produced (its `live_round_fetches_real_releases` test writes one).
#[test]
#[ignore = "needs a sample written by storyteller-web"]
fn reads_what_storyteller_web_serves() {
let Ok(path) = std::env::var("CRAFT_APPS_FEED_SAMPLE") else { return };
let json = std::fs::read_to_string(path).unwrap();
let feed = Feed::parse(&json).unwrap();
let now = feed.last_fetched.unwrap();
for app in [app("photocraft"), &LAUNCHER] {
let FeedAnswer::Fresh(Some(r)) = feed.answer(app, now) else { panic!("{} wasn't fresh", app.id) };
assert!(r.version.is_some(), "{}", app.id);
assert!(!r.assets.is_empty() && r.assets.iter().all(|a| a.sha256.is_some()), "{}", app.id);
assert!(r.assets.iter().all(|a| a.url.starts_with(&format!("https://github.com/{}/releases/download/", app.repo))));
}
}
#[test]
fn reads_notices_and_renames() {
let with_notices = sample()
.replace(
"\"announcements\": []",
r#""announcements": [{"title": "New: CADCraft 1.0", "body": "It's here.", "created_at": "2026-10-09T09:00:00Z"}]"#,
)
.replace(
"\"alerts\": []",
r#""alerts": [
{"title": "Security update", "body": "Update PdfCraft now.", "maybe_web_link": "https://getartcraft.com/security", "created_at": null},
{"title": "Bad link", "body": "x", "maybe_web_link": "javascript:alert(1)"}]"#,
)
.replace(
"\"app_renames\": []",
r#""app_renames": [{"old_name": "PdfCraft", "old_name_lower": "pdfcraft", "new_name": "DocuCraft",
"new_name_lower": "docucraft", "description": "Same app, new name.", "renamed_at": "2026-10-09T08:00:00Z"}]"#,
);
let feed = Feed::parse(&with_notices).unwrap();
assert_eq!(feed.alerts.len(), 2);
assert_eq!(feed.alerts[0].link.as_deref(), Some("https://getartcraft.com/security"));
assert_eq!(feed.alerts[1].link, None, "only web links are kept");
let titles: Vec<&str> = feed.announcements.iter().map(|n| n.title.as_str()).collect();
assert_eq!(titles, ["New: CADCraft 1.0", "PdfCraft is now DocuCraft"]);
// Ids are stable across parses, so dismissals stick.
assert_eq!(Feed::parse(&with_notices).unwrap().alerts[0].id, feed.alerts[0].id);
}
}

View File

@ -7,6 +7,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod catalog;
pub mod feed;
pub mod platform;
pub mod protocol;
pub mod release;

View File

@ -97,9 +97,29 @@ pub const PAUSED: &str = "paused";
/// The `JobFinished` error a cancelled job ends with (not a failure).
pub const CANCELLED: &str = "cancelled";
/// Which service an app's release information came from (the footer names them).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Origin {
/// ArtCraft's release feed (storyteller-web), which polls GitHub for everyone.
ArtCraftApi,
/// GitHub directly: the fallback.
GitHub,
}
impl Origin {
pub fn label(self) -> &'static str {
match self {
Self::ArtCraftApi => "ArtCraft API",
Self::GitHub => "GitHub",
}
}
}
/// Where release information came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Source {
/// ArtCraft's release feed (storyteller-web), which polls GitHub for everyone.
Feed,
/// The GitHub REST API.
GitHubApi,
/// github.com release pages (no API quota; used when the API is rate-limited).
@ -111,6 +131,7 @@ pub enum Source {
impl Source {
pub fn label(self) -> &'static str {
match self {
Self::Feed => "ArtCraft",
Self::GitHubApi => "GitHub",
Self::GitHubPages => "GitHub (release pages)",
Self::Cache => "saved results",
@ -124,11 +145,18 @@ pub enum Event {
Latest {
app: String,
latest: LatestRelease,
/// The service this answer came from.
from: Origin,
},
/// The latest ArtCraft Launcher release, when it's newer than this one.
LauncherUpdate(Release),
/// The new launcher is in place (or will be, once this one exits): quit now so it can start.
LauncherRestarting,
/// News and alerts from ArtCraft's release feed (replaces any earlier set).
Notices {
announcements: Vec<crate::feed::Notice>,
alerts: Vec<crate::feed::Notice>,
},
Detected {
app: String,
installed: Option<Installed>,

View File

@ -5,22 +5,41 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use artcraft_launcher_core::catalog::{LAUNCHER_ID, LAUNCHER_REPO};
use artcraft_launcher_core::catalog::{LAUNCHER, LAUNCHER_ID};
use artcraft_launcher_core::feed::{Feed, FeedAnswer};
use artcraft_launcher_core::platform::{Arch, Os, Platform, pick_asset};
use artcraft_launcher_core::protocol::{
Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, PAUSED, PausedDownload, Source,
Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PAUSED, PausedDownload, Source,
};
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::{CraftApp, Installed, PackageKind, SUITE, version};
use crate::feed_throttle::{self, FeedThrottle, Outcome};
use crate::http::{self, Http, Stop};
use crate::install::{self, Job};
use crate::manifest::Manifest;
use crate::paths::Paths;
use crate::source::{GitHubSource, ReleaseSource};
/// Automatic checks closer together than this reuse the last answers.
const FRESH_FOR: i64 = 10 * 60;
/// Automatic checks closer together than this reuse the last answers. The UI asks every five
/// minutes; this stops a burst of requests (refresh, focus, timers) from repeating work.
const FRESH_FOR: i64 = 4 * 60;
/// Once every app has loaded, GitHub is asked about an app the feed can't vouch for at most this
/// often (pressing Refresh asks right away).
const GITHUB_FALLBACK_EVERY: i64 = 60 * 60;
/// Until every app has loaded once, GitHub may be asked a little more eagerly.
const EARLY_GITHUB_EVERY: i64 = 5 * 60;
/// Tries at the feed per check (after the very first poll). The pause between tries is the
/// [`FeedThrottle`]'s: seconds during the first load, so a check retries; a minute or more after
/// that, so a check then tries once and the retries come from the next checks.
const FEED_ATTEMPTS: usize = 3;
/// The longest a check waits on the [`FeedThrottle`] before carrying on without the feed.
const MAX_THROTTLE_WAIT: i64 = 10;
/// After everything has loaded, the feed counts as seriously failed (and GitHub takes over)
/// only after this many checks in a row without an answer…
const SERIOUS_FEED_FAILURES: u32 = 3;
/// …and this long without a good answer.
const SERIOUS_FEED_OUTAGE: i64 = 60 * 60;
/// Requests in flight at once during a check.
const WORKERS: usize = 4;
@ -40,6 +59,18 @@ struct Shared {
last_check: Mutex<Option<i64>>,
/// The newer ArtCraft Launcher release, once a check has found one.
launcher_release: Mutex<Option<Release>>,
/// When GitHub was last asked about each app (Unix seconds), to keep the fallback rare.
github_asked: Mutex<HashMap<String, i64>>,
/// Apps with an answer (a release or "none yet") from either service.
answered: Mutex<std::collections::HashSet<String>>,
/// Every app has had an answer at least once: from then on GitHub is asked sparingly.
loaded: AtomicBool,
/// Checks in a row in which the feed didn't answer.
feed_failures: Mutex<u32>,
/// When the feed last answered (Unix seconds).
feed_ok_at: Mutex<Option<i64>>,
/// How often ArtCraft's API may be called.
feed_throttle: Mutex<FeedThrottle>,
}
impl Shared {
@ -87,6 +118,12 @@ impl NativeBackend {
checking: AtomicBool::new(false),
detecting: AtomicBool::new(false),
launcher_release: Mutex::new(None),
github_asked: Mutex::new(HashMap::new()),
answered: Mutex::new(std::collections::HashSet::new()),
loaded: AtomicBool::new(false),
feed_failures: Mutex::new(0),
feed_ok_at: Mutex::new(None),
feed_throttle: Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now())),
last_check: Mutex::new(None),
});
let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() };
@ -95,18 +132,28 @@ impl NativeBackend {
backend
}
/// Shows the saved answers from the last run straight away, before any network request.
/// Shows the saved answers from the last run straight away, before any network request: the
/// release feed's where they're still fresh, GitHub's otherwise.
fn replay_cache(&self) {
let Some(source) = &self.source else { return };
let pre = self.options().prereleases;
let feed = if pre { None } else { load_feed(&self.paths) };
let now = crate::now();
for app in &SUITE {
if let Some(latest) = source.cached(app.repo, pre) {
let answer = match feed.as_ref().map(|f| f.answer(app, now)) {
Some(FeedAnswer::Fresh(Some(r))) => Some((LatestRelease::Found(r), Origin::ArtCraftApi)),
Some(FeedAnswer::Fresh(None)) => Some((LatestRelease::None, Origin::ArtCraftApi)),
_ => self.source.as_ref().and_then(|s| s.cached(&feed_repo(feed.as_ref(), app), pre)).map(|l| (l, Origin::GitHub)),
};
if let Some((latest, from)) = answer {
if let LatestRelease::Found(r) = &latest {
self.shared.latest.lock().unwrap_or_else(PoisonError::into_inner).insert(app.id.to_string(), r.clone());
}
self.shared.emit(Event::Latest { app: app.id.to_string(), latest });
self.shared.emit(Event::Latest { app: app.id.to_string(), latest, from });
}
}
if let Some(f) = feed {
self.shared.emit(Event::Notices { announcements: f.announcements, alerts: f.alerts });
}
}
fn options(&self) -> BackendOptions {
@ -145,11 +192,17 @@ impl NativeBackend {
});
}
/// Looks up the latest releases: ArtCraft's release feed first (one request for every app),
/// GitHub for anything the feed can't vouch for (apps it doesn't list, entries more than a
/// day old, or no answer at all), at most once an hour per app unless `force`.
fn check(&self, force: bool) {
let Some(source) = self.source.clone() else {
let (Some(source), Some(http)) = (self.source.clone(), self.http.clone()) else {
for app in &SUITE {
self.shared
.emit(Event::Latest { app: app.id.to_string(), latest: LatestRelease::Failed("secure connections aren't available".into()) });
self.shared.emit(Event::Latest {
app: app.id.to_string(),
latest: LatestRelease::Failed("secure connections aren't available".into()),
from: Origin::GitHub,
});
}
return;
};
@ -163,40 +216,67 @@ impl NativeBackend {
let pre = self.options().prereleases;
std::thread::spawn(move || {
shared.emit(Event::CheckStarted);
let queue = Mutex::new(SUITE.iter().collect::<Vec<&CraftApp>>());
let (feed, fallback) = if pre {
// The feed serves stable releases only; with pre-releases on, GitHub decides.
(None, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY })
} else {
feed_and_fallback(&http, &paths, &shared)
};
if let Some(f) = &feed {
shared.emit(Event::Notices { announcements: f.announcements.clone(), alerts: f.alerts.clone() });
}
let now = crate::now();
let queue = Mutex::new(SUITE.iter().chain(std::iter::once(&LAUNCHER)).collect::<Vec<&CraftApp>>());
let sources = Mutex::new(Vec::new());
std::thread::scope(|scope| {
for _ in 0..WORKERS {
scope.spawn(|| {
loop {
let Some(app) = queue.lock().unwrap_or_else(PoisonError::into_inner).pop() else { break };
let answer = source.latest(app.repo, pre);
sources.lock().unwrap_or_else(PoisonError::into_inner).push(answer.source);
if let LatestRelease::Found(r) = &answer.latest {
let (latest, from, used) = latest_for(app, feed.as_ref(), fallback, source.as_ref(), &shared, pre, force, now);
if let Some(used) = used {
sources.lock().unwrap_or_else(PoisonError::into_inner).push(used);
}
if app.id == LAUNCHER_ID {
// Is there a newer ArtCraft Launcher?
if let Some(LatestRelease::Found(r)) = latest
&& let (Some(newest), Some(current)) = (r.version.as_ref(), crate::self_update::current_version())
&& version::is_newer(newest, &current)
&& !crate::self_update::just_updated_to(&paths, &r)
{
*shared.launcher_release.lock().unwrap_or_else(PoisonError::into_inner) = Some(r.clone());
shared.emit(Event::LauncherUpdate(r));
}
continue;
}
let Some(latest) = latest else { continue };
if let LatestRelease::Found(r) = &latest {
shared.latest.lock().unwrap_or_else(PoisonError::into_inner).insert(app.id.to_string(), r.clone());
}
shared.emit(Event::Latest { app: app.id.to_string(), latest: answer.latest });
if !matches!(latest, LatestRelease::Failed(_)) {
shared.answered.lock().unwrap_or_else(PoisonError::into_inner).insert(app.id.to_string());
}
shared.emit(Event::Latest { app: app.id.to_string(), latest, from });
}
});
}
});
// Is there a newer ArtCraft Launcher?
if let LatestRelease::Found(r) = source.latest(LAUNCHER_REPO, false).latest
&& let (Some(latest), Some(current)) = (r.version.as_ref(), crate::self_update::current_version())
&& version::is_newer(latest, &current)
&& !crate::self_update::just_updated_to(&paths, &r)
{
*shared.launcher_release.lock().unwrap_or_else(PoisonError::into_inner) = Some(r.clone());
shared.emit(Event::LauncherUpdate(r));
}
source.save();
{
let answered = shared.answered.lock().unwrap_or_else(PoisonError::into_inner);
if SUITE.iter().all(|a| answered.contains(a.id)) {
shared.loaded.store(true, Ordering::SeqCst);
}
}
let sources = sources.into_inner().unwrap_or_else(PoisonError::into_inner);
let source_used = if sources.contains(&Source::Cache) {
Source::Cache
} else if sources.contains(&Source::GitHubPages) {
Source::GitHubPages
} else {
} else if sources.contains(&Source::GitHubApi) {
Source::GitHubApi
} else {
Source::Feed
};
let at = crate::now();
*shared.last_check.lock().unwrap_or_else(PoisonError::into_inner) = Some(at);
@ -399,6 +479,182 @@ fn partial_for(app: &CraftApp, shared: &Shared, paths: &Paths, platform: Option<
Some((asset, done))
}
/// How much the feed is trusted in one check, and how freely GitHub may be asked.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Fallback {
/// Use the feed's answers (fresh entries only). Off when the feed has seriously failed.
trust_feed: bool,
/// Ask GitHub about an app at most this often (seconds), unless forced.
github_every: i64,
}
/// Asks the feed (retrying, except on the very first poll) and decides how much to lean on
/// GitHub this time:
///
/// - The very first poll (nothing has ever loaded, nothing saved): one try, then GitHub at once,
/// so the window fills.
/// - Until every app has loaded once: if the feed doesn't answer after its retries, GitHub is
/// asked fairly freely (every 5 minutes per app).
/// - After that: the feed's last answer stands in while it fails, and GitHub is asked only for
/// apps the feed doesn't list or hasn't refreshed in a day, or once the feed has seriously
/// failed (several checks in a row and an hour without an answer); then hourly per app.
fn feed_and_fallback(http: &Http, paths: &Paths, shared: &Shared) -> (Option<Feed>, Fallback) {
let saved = load_feed(paths);
let loaded = shared.loaded.load(Ordering::SeqCst);
let first_time = !loaded && saved.is_none();
let attempts = if first_time { 1 } else { FEED_ATTEMPTS };
let fetched = fetch_feed(http, paths, &feed_url(), &shared.feed_throttle, attempts);
let now = crate::now();
let (fresh, answered, failures) = {
let mut failures = shared.feed_failures.lock().unwrap_or_else(PoisonError::into_inner);
match fetched {
FeedFetch::Answered(feed) => {
*failures = 0;
*shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner) = Some(now);
(Some(feed), true, 0)
}
FeedFetch::Failed => {
*failures = failures.saturating_add(1);
(None, false, *failures)
}
// Not asked this time: things stand as the last request left them.
FeedFetch::Held => (None, *failures == 0 && saved.is_some(), *failures),
}
};
let ok_at = *shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner);
let fallback = decide_fallback(answered, loaded, first_time, failures, ok_at, now);
(fresh.or(saved), fallback)
}
/// The policy in [`feed_and_fallback`], on its own so it can be tested.
fn decide_fallback(answered: bool, loaded: bool, first_time: bool, failures: u32, ok_at: Option<i64>, now: i64) -> Fallback {
let github_every = if loaded { GITHUB_FALLBACK_EVERY } else { EARLY_GITHUB_EVERY };
let seriously_failed =
!answered && (first_time || !loaded || (failures >= SERIOUS_FEED_FAILURES && ok_at.is_none_or(|t| now - t >= SERIOUS_FEED_OUTAGE)));
Fallback { trust_feed: !seriously_failed, github_every }
}
/// The latest release of `app`, which service it came from, and the GitHub route used (for the
/// rate-limit notice): the feed when it's trusted and vouches for the app, else GitHub if it's
/// due, else GitHub's last answer. No release when there's nothing new to say.
#[allow(clippy::too_many_arguments)]
fn latest_for(
app: &CraftApp,
feed: Option<&Feed>,
fallback: Fallback,
source: &dyn ReleaseSource,
shared: &Shared,
pre: bool,
force: bool,
now: i64,
) -> (Option<LatestRelease>, Origin, Option<Source>) {
if fallback.trust_feed {
match feed.map(|f| f.answer(app, now)) {
Some(FeedAnswer::Fresh(Some(r))) => return (Some(LatestRelease::Found(r)), Origin::ArtCraftApi, Some(Source::Feed)),
Some(FeedAnswer::Fresh(None)) => return (Some(LatestRelease::None), Origin::ArtCraftApi, Some(Source::Feed)),
Some(FeedAnswer::AskGitHub) | None => {}
}
}
let repo = feed_repo(feed, app);
let due = {
let mut asked = shared.github_asked.lock().unwrap_or_else(PoisonError::into_inner);
let due = force || asked.get(app.id).is_none_or(|t| now - t >= fallback.github_every);
if due {
asked.insert(app.id.to_string(), now);
}
due
};
if due {
let answer = source.latest(&repo, pre);
(Some(answer.latest), Origin::GitHub, Some(answer.source))
} else {
(source.cached(&repo, pre), Origin::GitHub, None)
}
}
/// The repo to ask GitHub about, following the feed's repo renames.
fn feed_repo(feed: Option<&Feed>, app: &CraftApp) -> String {
feed.map_or_else(|| app.repo.to_string(), |f| f.repo_for(app))
}
/// What came of asking the feed during one check.
enum FeedFetch {
Answered(Feed),
/// Asked, without a good answer.
Failed,
/// Not asked: the [`FeedThrottle`] said to hold off.
Held,
}
/// Asks ArtCraft's release feed up to `attempts` times, as often as `throttle` allows (waiting at
/// most [`MAX_THROTTLE_WAIT`] for it), saving a good answer.
fn fetch_feed(http: &Http, paths: &Paths, url: &str, throttle: &Mutex<FeedThrottle>, attempts: usize) -> FeedFetch {
let mut asked = false;
for attempt in 0..attempts.max(1) {
let wait = throttle.lock().unwrap_or_else(PoisonError::into_inner).wait(crate::now());
if wait > MAX_THROTTLE_WAIT {
if !asked {
log::info!("holding off the ArtCraft API for {wait} s");
}
break;
}
if wait > 0 {
std::thread::sleep(Duration::from_secs(wait.unsigned_abs()));
}
{
let mut throttle = throttle.lock().unwrap_or_else(PoisonError::into_inner);
let now = crate::now();
if throttle.wait(now) > 0 {
break;
}
throttle.sending(now);
}
asked = true;
let outcome = match http.get_feed(url) {
Ok(body) => match Feed::parse(&body) {
Some(feed) => {
if let Err(e) = crate::paths::write_atomic(&paths.feed_cache(), body.as_bytes()) {
log::warn!("couldn't save the release feed: {e}");
}
throttle.lock().unwrap_or_else(PoisonError::into_inner).finished(Outcome::Answered, crate::now(), 0.0);
return FeedFetch::Answered(feed);
}
None => {
log::warn!("the release feed answered with something unreadable (try {})", attempt + 1);
Outcome::Failed { retry_after: None }
}
},
Err(e) if e.throttled => {
log::warn!("the ArtCraft API asked us to slow down (HTTP 429); holding off");
Outcome::Throttled { retry_after: e.retry_after }
}
Err(e) => {
log::info!("release feed unavailable (try {}): {}", attempt + 1, e.why);
Outcome::Failed { retry_after: e.retry_after }
}
};
throttle.lock().unwrap_or_else(PoisonError::into_inner).finished(outcome, crate::now(), feed_throttle::jitter());
if matches!(outcome, Outcome::Throttled { .. }) {
break;
}
}
if asked { FeedFetch::Failed } else { FeedFetch::Held }
}
/// The feed's address. `ARTCRAFT_LAUNCHER_FEED_URL` points at a staging server or a local copy
/// (only `https://`, or plain `http://` on localhost).
fn feed_url() -> String {
std::env::var("ARTCRAFT_LAUNCHER_FEED_URL")
.ok()
.filter(|u| u.starts_with("https://") || u.starts_with("http://127.0.0.1:") || u.starts_with("http://localhost:"))
.unwrap_or_else(|| artcraft_launcher_core::feed::FEED_URL.to_string())
}
/// The last saved feed answer, if any.
fn load_feed(paths: &Paths) -> Option<Feed> {
std::fs::read_to_string(paths.feed_cache()).ok().and_then(|body| Feed::parse(&body))
}
/// The machine's real architecture: an x64 launcher on Windows on ARM should still pick ARM64
/// builds.
fn native_platform() -> Option<Platform> {
@ -505,3 +761,188 @@ pub fn launcher_download(release: &Release, platform: Platform) -> Option<String
};
pick_asset(release, &[LAUNCHER_ID], platform, kinds).map(|(a, _)| a.url.clone())
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use artcraft_launcher_core::protocol::LatestRelease;
use super::*;
use crate::source::Answer;
/// GitHub, counting how often it's asked.
#[derive(Default)]
struct CountingGitHub(AtomicUsize);
impl ReleaseSource for CountingGitHub {
fn latest(&self, _repo: &str, _pre: bool) -> Answer {
self.0.fetch_add(1, Ordering::SeqCst);
Answer { latest: LatestRelease::None, source: Source::GitHubApi }
}
fn cached(&self, _repo: &str, _pre: bool) -> Option<LatestRelease> {
None
}
fn rate_limited_until(&self) -> Option<i64> {
None
}
fn save(&self) {}
}
fn shared() -> Shared {
Shared {
events: Mutex::new(Vec::new()),
waker: Arc::new(|| {}),
latest: Mutex::new(HashMap::new()),
installed: Mutex::new(HashMap::new()),
jobs: Mutex::new(HashMap::new()),
manifest: Mutex::new(Manifest::default()),
options: Mutex::new(BackendOptions::default()),
checking: AtomicBool::new(false),
detecting: AtomicBool::new(false),
last_check: Mutex::new(None),
launcher_release: Mutex::new(None),
github_asked: Mutex::new(HashMap::new()),
answered: Mutex::new(std::collections::HashSet::new()),
loaded: AtomicBool::new(false),
feed_failures: Mutex::new(0),
feed_ok_at: Mutex::new(None),
feed_throttle: Mutex::new(FeedThrottle::new()),
}
}
/// A local server answering every request with `status` (and `Retry-After: 120` on a 429),
/// counting the requests.
fn serve(status: &'static str) -> (String, Arc<AtomicUsize>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/v1/craft_apps/release_info", listener.local_addr().unwrap());
let hits = Arc::new(AtomicUsize::new(0));
let counted = hits.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
counted.fetch_add(1, Ordering::SeqCst);
let extra = if status.starts_with("429") { "Retry-After: 120\r\n" } else { "" };
let _ = write!(stream, "HTTP/1.1 {status}\r\n{extra}Content-Length: 0\r\nConnection: close\r\n\r\n");
}
});
(url, hits)
}
#[test]
fn a_429_stops_the_retries_and_holds_off_the_api() {
let (url, hits) = serve("429 Too Many Requests");
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new().unwrap();
let throttle = Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now()));
assert!(matches!(fetch_feed(&http, &paths, &url, &throttle, FEED_ATTEMPTS), FeedFetch::Failed));
assert_eq!(hits.load(Ordering::SeqCst), 1);
// The next checks (and a relaunch) don't call it at all.
assert!(matches!(fetch_feed(&http, &paths, &url, &throttle, FEED_ATTEMPTS), FeedFetch::Held));
let relaunched = Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now()));
assert!(matches!(fetch_feed(&http, &paths, &url, &relaunched, FEED_ATTEMPTS), FeedFetch::Held));
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[test]
fn a_failing_api_is_retried_quickly_only_during_the_first_load() {
let (url, hits) = serve("503 Service Unavailable");
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new().unwrap();
let throttle = Mutex::new(FeedThrottle::new());
// First load: three tries, a couple of seconds apart.
assert!(matches!(fetch_feed(&http, &paths, &url, &throttle, FEED_ATTEMPTS), FeedFetch::Failed));
assert_eq!(hits.load(Ordering::SeqCst), 3);
// Once the server has answered, never more than one a minute.
throttle.lock().unwrap().finished(Outcome::Answered, crate::now(), 0.0);
assert!(matches!(fetch_feed(&http, &paths, &url, &throttle, FEED_ATTEMPTS), FeedFetch::Held));
assert_eq!(hits.load(Ordering::SeqCst), 3);
}
const NOW: i64 = 1_791_500_000;
/// The steady state: everything has loaded and the feed is answering.
const LOADED: Fallback = Fallback { trust_feed: true, github_every: GITHUB_FALLBACK_EVERY };
fn feed() -> Feed {
Feed::parse(
r#"{"release_info": {"apps": {
"photocraft": {"repo": "storytold/photocraft", "latest_release": {"tag": "v0.6.0"}, "app_last_fetched": "2026-10-09T13:00:00Z"}
}}}"#,
)
.unwrap()
}
#[test]
fn the_first_poll_tries_once_and_falls_back_at_once() {
// Nothing has ever loaded and the feed didn't answer: GitHub, freely.
let f = decide_fallback(false, false, true, 1, None, NOW);
assert_eq!(f, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY });
// The feed answered: trust it.
assert!(decide_fallback(true, false, true, 0, Some(NOW), NOW).trust_feed);
}
#[test]
fn before_everything_loads_github_is_asked_more_eagerly() {
// A saved feed but not everything loaded yet, and the feed isn't answering.
let f = decide_fallback(false, false, false, 1, None, NOW);
assert_eq!(f, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY });
}
#[test]
fn after_loading_github_waits_for_a_serious_feed_failure() {
// A blip or two: keep showing the feed's last answer.
for failures in 1..SERIOUS_FEED_FAILURES {
let f = decide_fallback(false, true, false, failures, Some(NOW - 10 * 60), NOW);
assert_eq!(f, Fallback { trust_feed: true, github_every: GITHUB_FALLBACK_EVERY }, "{failures}");
}
// Several checks in a row, but it answered recently: still trusted.
assert!(decide_fallback(false, true, false, SERIOUS_FEED_FAILURES, Some(NOW - 20 * 60), NOW).trust_feed);
// Several checks in a row and an hour without an answer: GitHub takes over, hourly.
let f = decide_fallback(false, true, false, SERIOUS_FEED_FAILURES, Some(NOW - SERIOUS_FEED_OUTAGE), NOW);
assert_eq!(f, Fallback { trust_feed: false, github_every: GITHUB_FALLBACK_EVERY });
}
#[test]
fn github_is_only_the_fallback_and_is_asked_rarely() {
let github = CountingGitHub::default();
let shared = shared();
let photo = CraftApp::by_id("photocraft").unwrap();
let word = CraftApp::by_id("wordcraft").unwrap();
let feed = feed();
// The feed vouches for PhotoCraft: GitHub isn't asked.
let (latest, _, used) = latest_for(photo, Some(&feed), LOADED, &github, &shared, false, false, NOW);
assert!(matches!(latest, Some(LatestRelease::Found(ref r)) if r.tag == "v0.6.0"));
assert_eq!(used, Some(Source::Feed));
assert_eq!(github.0.load(Ordering::SeqCst), 0);
// The feed doesn't list WordCraft: GitHub is asked once…
let (_, _, used) = latest_for(word, Some(&feed), LOADED, &github, &shared, false, false, NOW);
assert_eq!(used, Some(Source::GitHubApi));
assert_eq!(github.0.load(Ordering::SeqCst), 1);
// …not again within the hour…
let (_, _, used) = latest_for(word, Some(&feed), LOADED, &github, &shared, false, false, NOW + 30 * 60);
assert_eq!(used, None);
assert_eq!(github.0.load(Ordering::SeqCst), 1);
// …but Refresh asks right away, and an hour later it's due again.
latest_for(word, Some(&feed), LOADED, &github, &shared, false, true, NOW + 31 * 60);
assert_eq!(github.0.load(Ordering::SeqCst), 2);
latest_for(word, Some(&feed), LOADED, &github, &shared, false, false, NOW + 31 * 60 + GITHUB_FALLBACK_EVERY);
assert_eq!(github.0.load(Ordering::SeqCst), 3);
// No feed answer at all (server down): GitHub, still rate-limited per app.
let (_, _, used) = latest_for(photo, None, LOADED, &github, &shared, false, false, NOW);
assert_eq!(used, Some(Source::GitHubApi));
assert_eq!(github.0.load(Ordering::SeqCst), 4);
// A feed entry more than a day old doesn't count.
let fresh_shared = super::tests::shared();
let (_, _, used) = latest_for(photo, Some(&feed), LOADED, &github, &fresh_shared, false, false, NOW + 2 * 86_400);
assert_eq!(used, Some(Source::GitHubApi));
}
}

View File

@ -0,0 +1,378 @@
//! [`FeedThrottle`]: how often the launcher may call ArtCraft's API (the release feed).
//!
//! Every launcher in the world polls the same server, so the limits are strict and favour the
//! server over freshness (the last saved answer always stands in):
//!
//! - **At most one request a minute** once the first load is over.
//! - **During the first load** (until the feed first answers, at most [`WARM_REQUESTS`]
//! requests), up to ten a minute: front-loaded, with the gaps doubling after each failure
//! (2 s, 4 s, 8 s, …), so a struggling server sees each client ease off quickly.
//! - **Failures back off**: after a failed request the next waits 2, 4, 8 … minutes (at most an
//! hour), with jitter.
//! - **HTTP 429 throttles hard**: at least 15 minutes, doubling with each 429 in a row up to six
//! hours, never sooner than the server's `Retry-After`, plus up to 50% jitter so a herd of
//! clients doesn't come back in step. This survives restarts (saved to disk), so relaunching
//! doesn't skip the wait.
use std::collections::VecDeque;
use std::hash::{BuildHasher, Hasher};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The window the per-minute limits count over (seconds).
const WINDOW: i64 = 60;
/// Requests per [`WINDOW`] once the first load is over.
const STEADY_PER_WINDOW: usize = 1;
/// Requests per [`WINDOW`] during the first load.
const WARM_PER_WINDOW: usize = 10;
/// The first load ends at the first good answer, or after this many requests.
const WARM_REQUESTS: u32 = 10;
/// Gaps between first-load requests after 0, 1, 2 … failures in a row (seconds).
const WARM_GAPS: [i64; 6] = [1, 2, 4, 8, 16, 32];
/// After a failure (once loaded), the wait before the next request: this, doubled per failure in
/// a row…
const FAILURE_BACKOFF: i64 = 2 * 60;
/// …up to this.
const MAX_FAILURE_BACKOFF: i64 = 60 * 60;
/// After an HTTP 429, the wait before the next request: this, doubled per 429 in a row…
const THROTTLE_BACKOFF: i64 = 15 * 60;
/// …up to this.
const MAX_THROTTLE_BACKOFF: i64 = 6 * 60 * 60;
/// A `Retry-After` beyond this is ignored as nonsense (we still wait our own backoff).
const MAX_RETRY_AFTER: i64 = 24 * 60 * 60;
/// What happened to one request, as far as pacing is concerned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
/// A readable answer.
Answered,
/// No answer, an error status or an unreadable body: the server may be struggling.
/// `retry_after` is the server's `Retry-After` (seconds), if it sent one.
Failed { retry_after: Option<i64> },
/// HTTP 429 Too Many Requests.
Throttled { retry_after: Option<i64> },
}
/// The part that outlives the process, so a relaunch keeps backing off.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
struct Saved {
/// No request before this (Unix seconds).
not_before: i64,
/// Failed requests in a row.
failures: u32,
/// HTTP 429s in a row.
throttles: u32,
}
/// The pacing state for ArtCraft's API. Times are Unix seconds, passed in so it can be tested.
#[derive(Debug)]
pub struct FeedThrottle {
saved: Saved,
/// When recent requests were sent (the last [`WINDOW`] seconds' worth).
sent: VecDeque<i64>,
/// Requests sent during the first load.
warm_sent: u32,
/// The first load is over: the feed has answered, or we've used up the first-load requests.
warmed: bool,
/// Where the state is saved, if anywhere.
file: Option<PathBuf>,
}
impl FeedThrottle {
/// Fresh state, not saved anywhere.
pub fn new() -> Self {
Self { saved: Saved::default(), sent: VecDeque::new(), warm_sent: 0, warmed: false, file: None }
}
/// The state saved at `file` by an earlier run (if readable and sane), saved back there as it
/// changes. A missing or malformed file just means no backoff is pending.
pub fn load(file: &Path, now: i64) -> Self {
let mut saved = std::fs::read(file).ok().and_then(|b| serde_json::from_slice::<Saved>(&b).ok()).unwrap_or_default();
// A wait far in the future means a broken file or a clock that jumped back: drop it.
if saved.not_before > now + MAX_THROTTLE_BACKOFF * 2 {
saved.not_before = 0;
}
Self { saved, file: Some(file.to_path_buf()), ..Self::new() }
}
/// Seconds to wait before the next request may be sent (0: now).
pub fn wait(&self, now: i64) -> i64 {
let warming = !self.warmed;
let per_window = if warming { WARM_PER_WINDOW } else { STEADY_PER_WINDOW };
let mut at = self.saved.not_before;
let recent: Vec<i64> = self.sent.iter().copied().filter(|t| now - t < WINDOW).collect();
if recent.len() >= per_window
&& let Some(oldest) = recent.get(recent.len() - per_window)
{
at = at.max(oldest + WINDOW);
}
if warming && let Some(last) = recent.last() {
let gap = WARM_GAPS.get(self.saved.failures as usize).or(WARM_GAPS.last()).copied().unwrap_or(WINDOW);
at = at.max(last + gap);
}
(at - now).max(0)
}
/// Notes that a request is being sent now.
pub fn sending(&mut self, now: i64) {
self.sent.push_back(now);
while self.sent.front().is_some_and(|t| now - t >= WINDOW) {
self.sent.pop_front();
}
if !self.warmed {
self.warm_sent += 1;
if self.warm_sent >= WARM_REQUESTS {
self.warmed = true;
}
}
}
/// Notes how the request went. `jitter` is in `0.0..1.0` (see [`jitter`]).
pub fn finished(&mut self, outcome: Outcome, now: i64, jitter: f64) {
let before = self.saved;
match outcome {
Outcome::Answered => {
self.warmed = true;
self.saved = Saved::default();
}
Outcome::Failed { retry_after } => {
self.saved.failures = self.saved.failures.saturating_add(1);
self.saved.throttles = 0;
// During the first load the doubling gaps in `wait` do the backing off.
let backoff = if self.warmed { doubled(FAILURE_BACKOFF, self.saved.failures, MAX_FAILURE_BACKOFF) } else { 0 };
let backoff = spread(backoff, 0.4 * jitter - 0.2);
self.saved.not_before = now + backoff.max(sane(retry_after));
}
Outcome::Throttled { retry_after } => {
self.warmed = true;
self.saved.throttles = self.saved.throttles.saturating_add(1);
let backoff = doubled(THROTTLE_BACKOFF, self.saved.throttles, MAX_THROTTLE_BACKOFF);
// Only ever later than asked: spreading the herd out, never bringing it back early.
self.saved.not_before = now + spread(backoff.max(sane(retry_after)), 0.5 * jitter);
}
}
if self.saved != before {
self.save();
}
}
/// The server sent HTTP 429 recently and we're still waiting it out.
pub fn throttled(&self, now: i64) -> bool {
self.saved.throttles > 0 && self.saved.not_before > now
}
fn save(&self) {
let Some(file) = &self.file else { return };
let saved = serde_json::to_vec(&self.saved).unwrap_or_default();
if let Err(e) = crate::paths::write_atomic(file, &saved) {
log::warn!("couldn't save the ArtCraft API backoff: {e}");
}
}
}
impl Default for FeedThrottle {
fn default() -> Self {
Self::new()
}
}
/// `base` doubled for each repeat after the first (`times` ≥ 1), at most `max`.
fn doubled(base: i64, times: u32, max: i64) -> i64 {
let doublings = times.saturating_sub(1).min(16);
base.saturating_mul(1 << doublings).min(max)
}
/// `secs` scaled by `1 + by`.
fn spread(secs: i64, by: f64) -> i64 {
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
let scaled = (secs as f64 * (1.0 + by)).round() as i64;
scaled.max(0)
}
/// A `Retry-After` in seconds, ignoring negative or absurd values.
fn sane(retry_after: Option<i64>) -> i64 {
retry_after.filter(|s| (0..=MAX_RETRY_AFTER).contains(s)).unwrap_or(0)
}
/// A random number in `0.0..1.0`, different in every launcher (std's hasher keys are random).
pub fn jitter() -> f64 {
let bits = std::collections::hash_map::RandomState::new().build_hasher().finish();
#[allow(clippy::cast_precision_loss)]
let fraction = (bits >> 11) as f64 / (1u64 << 53) as f64;
fraction
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_791_500_000;
/// Sends requests as fast as the throttle allows for `secs`, each failing with `outcome`.
/// Returns when they were sent.
fn hammer(t: &mut FeedThrottle, from: i64, secs: i64, outcome: Outcome) -> Vec<i64> {
let mut sent = Vec::new();
let mut now = from;
while now < from + secs {
let wait = t.wait(now);
if wait > 0 {
now += wait;
continue;
}
t.sending(now);
sent.push(now);
t.finished(outcome, now, 0.5);
}
sent.retain(|s| *s < from + secs);
sent
}
fn per_minute_max(sent: &[i64]) -> usize {
sent.iter().map(|s| sent.iter().filter(|t| **t >= *s && **t < s + WINDOW).count()).max().unwrap_or(0)
}
#[test]
fn once_loaded_never_more_than_one_request_a_minute() {
let mut t = FeedThrottle::new();
t.sending(NOW);
t.finished(Outcome::Answered, NOW, 0.5);
let sent = hammer(&mut t, NOW, 3600, Outcome::Answered);
assert_eq!(per_minute_max(&sent), 1);
assert_eq!(sent.len(), 59);
assert_eq!(sent.first(), Some(&(NOW + 60)));
}
#[test]
fn the_first_load_may_go_faster_but_never_past_ten_a_minute() {
let mut t = FeedThrottle::new();
let sent = hammer(&mut t, NOW, 60, Outcome::Failed { retry_after: None });
assert!(sent.len() > 1 && per_minute_max(&sent) <= 10, "{sent:?}");
// Front-loaded and falling off: the gaps grow with each failure.
let gaps: Vec<i64> = sent.windows(2).map(|w| w[1] - w[0]).collect();
assert!(gaps.windows(2).all(|g| g[1] >= g[0]), "{gaps:?}");
assert_eq!(sent.first(), Some(&NOW));
}
#[test]
fn a_healthy_first_load_retries_quickly() {
// Even with every request succeeding, after the first answer it's one a minute.
let mut t = FeedThrottle::new();
let sent = hammer(&mut t, NOW, 120, Outcome::Answered);
assert_eq!(sent, vec![NOW, NOW + 60]);
}
#[test]
fn the_first_load_ends_after_ten_requests_even_without_an_answer() {
let mut t = FeedThrottle::new();
for i in 0..WARM_REQUESTS {
t.sending(NOW + i64::from(i));
}
// Back to one a minute.
assert!(t.wait(NOW + 10) >= 50);
}
#[test]
fn failures_back_off_after_loading() {
let mut t = FeedThrottle::new();
t.sending(NOW);
t.finished(Outcome::Answered, NOW, 0.5);
let sent = hammer(&mut t, NOW + 60, 6 * 3600, Outcome::Failed { retry_after: None });
let gaps: Vec<i64> = sent.windows(2).map(|w| w[1] - w[0]).collect();
assert_eq!(gaps.first(), Some(&(2 * 60)));
assert!(gaps.windows(2).all(|g| g[1] >= g[0]), "{gaps:?}");
assert_eq!(gaps.last(), Some(&MAX_FAILURE_BACKOFF));
}
#[test]
fn a_good_answer_resets_the_backoff() {
let mut t = FeedThrottle::new();
t.warmed = true;
for i in 0..5 {
t.finished(Outcome::Failed { retry_after: None }, NOW + i, 0.5);
}
t.finished(Outcome::Answered, NOW + 10, 0.5);
assert_eq!(t.wait(NOW + 10), 0);
}
#[test]
fn a_429_throttles_hard_and_harder() {
let mut t = FeedThrottle::new();
t.sending(NOW);
t.finished(Outcome::Throttled { retry_after: None }, NOW, 0.0);
assert_eq!(t.wait(NOW), THROTTLE_BACKOFF);
assert!(t.throttled(NOW));
let sent = hammer(&mut t, NOW, 24 * 3600, Outcome::Throttled { retry_after: None });
let gaps: Vec<i64> = sent.windows(2).map(|w| w[1] - w[0]).collect();
assert!(gaps.windows(2).all(|g| g[1] >= g[0]), "{gaps:?}");
assert!(gaps.iter().all(|g| *g >= THROTTLE_BACKOFF));
assert!(gaps.last().is_some_and(|g| *g >= MAX_THROTTLE_BACKOFF));
}
#[test]
fn a_429_during_the_first_load_ends_the_fast_start() {
let mut t = FeedThrottle::new();
t.sending(NOW);
t.finished(Outcome::Throttled { retry_after: None }, NOW, 0.0);
let sent = hammer(&mut t, NOW + 1, 3600, Outcome::Throttled { retry_after: None });
assert!(sent.iter().all(|s| *s >= NOW + THROTTLE_BACKOFF), "{sent:?}");
}
#[test]
fn retry_after_is_honoured_but_never_shortens_our_wait() {
let mut t = FeedThrottle::new();
t.finished(Outcome::Throttled { retry_after: Some(3 * 3600) }, NOW, 0.0);
assert_eq!(t.wait(NOW), 3 * 3600);
let mut t = FeedThrottle::new();
t.finished(Outcome::Throttled { retry_after: Some(5) }, NOW, 0.0);
assert_eq!(t.wait(NOW), THROTTLE_BACKOFF);
// Nonsense is ignored.
let mut t = FeedThrottle::new();
t.finished(Outcome::Throttled { retry_after: Some(i64::MAX) }, NOW, 0.0);
assert_eq!(t.wait(NOW), THROTTLE_BACKOFF);
// A 503 with Retry-After is honoured too.
let mut t = FeedThrottle::new();
t.finished(Outcome::Failed { retry_after: Some(600) }, NOW, 0.5);
assert_eq!(t.wait(NOW), 600);
}
#[test]
fn jitter_only_ever_delays_a_429() {
for j in [0.0, 0.25, 0.5, 0.99] {
let mut t = FeedThrottle::new();
t.finished(Outcome::Throttled { retry_after: None }, NOW, j);
let wait = t.wait(NOW);
assert!((THROTTLE_BACKOFF..=THROTTLE_BACKOFF * 3 / 2).contains(&wait), "{j}: {wait}");
}
let j = jitter();
assert!((0.0..1.0).contains(&j));
}
#[test]
fn the_backoff_survives_a_restart() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("feed-throttle.json");
let mut t = FeedThrottle::load(&file, NOW);
t.finished(Outcome::Throttled { retry_after: None }, NOW, 0.0);
let again = FeedThrottle::load(&file, NOW + 60);
assert_eq!(again.wait(NOW + 60), THROTTLE_BACKOFF - 60);
assert!(again.throttled(NOW + 60));
// A good answer clears it.
let mut again = again;
again.finished(Outcome::Answered, NOW + THROTTLE_BACKOFF, 0.0);
assert_eq!(FeedThrottle::load(&file, NOW + THROTTLE_BACKOFF).wait(NOW + THROTTLE_BACKOFF), 0);
}
#[test]
fn a_broken_or_absurd_saved_file_means_no_wait() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("feed-throttle.json");
std::fs::write(&file, b"{not json").unwrap();
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
std::fs::write(&file, br#"{"not_before": 99999999999, "throttles": 3}"#).unwrap();
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
std::fs::write(&file, br#"{"unknown": true}"#).unwrap();
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
}
}

View File

@ -32,11 +32,23 @@ pub enum Fetched {
#[derive(Clone)]
pub struct Http {
small: ureq::Agent,
/// ArtCraft's release feed: a short timeout, so a slow server never holds up a check.
feed: ureq::Agent,
no_redirect: ureq::Agent,
download: ureq::Agent,
token: Option<String>,
}
/// Why ArtCraft's release feed couldn't be read.
#[derive(Debug)]
pub struct FeedError {
pub why: String,
/// The server answered HTTP 429 Too Many Requests.
pub throttled: bool,
/// The server's `Retry-After`, in seconds.
pub retry_after: Option<i64>,
}
impl Http {
pub fn new() -> Result<Self, String> {
let roots = os_roots()?;
@ -48,6 +60,14 @@ impl Http {
.user_agent(USER_AGENT)
.build()
.new_agent();
let feed = ureq::Agent::config_builder()
.tls_config(tls())
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_global(Some(Duration::from_secs(10)))
.http_status_as_error(false)
.user_agent(USER_AGENT)
.build()
.new_agent();
let no_redirect = ureq::Agent::config_builder()
.tls_config(tls())
.timeout_global(Some(Duration::from_secs(20)))
@ -70,7 +90,7 @@ impl Http {
// An optional token raises GitHub's limit from 60 to 5000 requests an hour. Only ever sent
// to api.github.com.
let token = std::env::var("ARTCRAFT_LAUNCHER_GITHUB_TOKEN").ok().map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
Ok(Self { small, no_redirect, download, token })
Ok(Self { small, feed, no_redirect, download, token })
}
/// GET a GitHub API resource, revalidating with `etag` when given.
@ -110,6 +130,19 @@ impl Http {
}
}
/// ArtCraft's release feed, or why it couldn't be read. Never takes more than ~10 seconds.
pub fn get_feed(&self, url: &str) -> Result<String, FeedError> {
let failed = |why: String| FeedError { why, throttled: false, retry_after: None };
let mut resp =
self.feed.get(url).header("Accept", "application/json").call().map_err(|e| failed(format!("couldn't reach {} ({e})", host(url))))?;
// Seconds only; an HTTP date falls back to our own backoff.
let retry_after = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).and_then(|r| r.trim().parse::<i64>().ok());
match resp.status().as_u16() {
200 => resp.body_mut().with_config().limit(MAX_SMALL).read_to_string().map_err(|e| failed(format!("unreadable answer ({e})"))),
s => Err(FeedError { why: format!("{} answered HTTP {s}", host(url)), throttled: s == 429, retry_after }),
}
}
/// GET a small text file (following redirects).
pub fn get_text(&self, url: &str) -> Result<Option<String>, String> {
let mut resp = self.small.get(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;

View File

@ -9,6 +9,7 @@
pub mod backend;
pub mod detect;
pub mod feed_throttle;
pub mod http;
pub mod install;
pub mod login;

View File

@ -36,6 +36,16 @@ impl Paths {
self.cache.join("releases.json")
}
/// The last answer from ArtCraft's release feed, for an instant first paint and as a fallback.
pub fn feed_cache(&self) -> PathBuf {
self.cache.join("feed.json")
}
/// How long to hold off calling ArtCraft's API (after failures or HTTP 429), across restarts.
pub fn feed_throttle(&self) -> PathBuf {
self.cache.join("feed-throttle.json")
}
pub fn manifest(&self) -> PathBuf {
self.data.join("installed.json")
}

View File

@ -80,6 +80,10 @@ pub struct LauncherUi {
pub(crate) launcher_job: Option<artcraft_launcher_core::protocol::JobPhase>,
/// The new launcher is in place: quit so it can start.
restart_requested: bool,
/// News from ArtCraft's release feed, shown inline until dismissed.
pub(crate) announcements: Vec<artcraft_launcher_core::feed::Notice>,
/// Alerts from the feed, shown as popups until acknowledged.
pub(crate) alerts: Vec<artcraft_launcher_core::feed::Notice>,
pub(crate) search: String,
pub(crate) toasts: Vec<Toast>,
pub(crate) dialog: Option<Dialog>,
@ -119,6 +123,8 @@ impl LauncherUi {
launcher_update: None,
launcher_job: None,
restart_requested: false,
announcements: Vec::new(),
alerts: Vec::new(),
search: String::new(),
toasts: Vec::new(),
dialog: None,
@ -211,11 +217,12 @@ impl LauncherUi {
/// Returns whether app state changed.
fn handle(&mut self, ctx: &egui::Context, event: Event) -> bool {
match event {
Event::Latest { app, latest } => {
Event::Latest { app, latest, from } => {
if let Some(e) = self.entry_mut(&app) {
// A failed check keeps the release we already know about.
// A failed check keeps the release we already know about (and where it came from).
if !(matches!(latest, LatestRelease::Failed(_)) && matches!(e.latest, Some(LatestRelease::Found(_)))) {
e.latest = Some(latest);
e.origin = Some(from);
}
}
true
@ -239,6 +246,11 @@ impl LauncherUi {
self.restart_requested = true;
false
}
Event::Notices { announcements, alerts } => {
self.announcements = announcements;
self.alerts = alerts;
false
}
Event::Paused { app, download } => {
if let Some(e) = self.entry_mut(&app) {
e.paused = download;
@ -432,6 +444,29 @@ impl LauncherUi {
list.into_iter().map(|(_, e)| e.app.id).collect()
}
/// "ArtCraft API", "GitHub" or "ArtCraft API and GitHub": the services behind the releases on
/// screen right now. `None` until something has loaded.
pub(crate) fn sources_label(&self) -> Option<String> {
use artcraft_launcher_core::protocol::Origin;
let mut api = false;
let mut github = false;
for e in &self.entries {
if matches!(e.latest, Some(LatestRelease::Found(_) | LatestRelease::None)) {
match e.origin {
Some(Origin::ArtCraftApi) => api = true,
Some(Origin::GitHub) => github = true,
None => {}
}
}
}
match (api, github) {
(true, true) => Some(format!("{} and {}", Origin::ArtCraftApi.label(), Origin::GitHub.label())),
(true, false) => Some(Origin::ArtCraftApi.label().to_string()),
(false, true) => Some(Origin::GitHub.label().to_string()),
(false, false) => None,
}
}
pub(crate) fn count(&self, filter: Filter) -> usize {
self.entries
.iter()

View File

@ -11,7 +11,10 @@ use crate::widgets::{self, Cell, Kind};
impl LauncherUi {
pub(crate) fn dialogs(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
let Some(dialog) = self.dialog.clone() else { return };
let Some(dialog) = self.dialog.clone() else {
self.alert_popup(ctx);
return;
};
let t = Tokens::get(ctx);
let modal = egui::Modal::new(egui::Id::new("dialog"))
.frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
@ -26,6 +29,36 @@ impl LauncherUi {
}
}
/// The first alert from the release feed the user hasn't acknowledged, as a popup.
fn alert_popup(&mut self, ctx: &egui::Context) {
let Some(alert) = self.alerts.iter().find(|a| !self.settings.dismissed_notices.contains(&a.id)).cloned() else { return };
let t = Tokens::get(ctx);
let modal = egui::Modal::new(egui::Id::new("feed_alert"))
.frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
let mut ok = false;
modal.show(ctx, |ui| {
ui.set_width(440.0f32.min(ctx.content_rect().width() - 80.0));
ui.label(RichText::new(&alert.title).size(18.0).family(theme::semibold()).color(t.ink_strong));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(ctx.content_rect().height() * 0.5).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(&alert.body).color(t.muted)).wrap());
});
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ok = widgets::button(ui, "OK", Kind::Primary, 90.0).clicked();
if let Some(link) = &alert.link
&& widgets::button(ui, "Open link", Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked()
{
ui.ctx().open_url(egui::OpenUrl::new_tab(link.clone()));
}
});
});
// Only OK acknowledges it: Escape or a click outside leaves it for later.
if ok {
self.settings.dismissed_notices.insert(alert.id);
}
}
fn title(ui: &mut egui::Ui, icon: Option<egui::ImageSource<'static>>, title: &str, subtitle: &str) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {

View File

@ -14,13 +14,15 @@ pub struct AppEntry {
/// Detection has answered at least once.
pub detected: bool,
pub job: Option<JobPhase>,
/// The service `latest` came from.
pub origin: Option<artcraft_launcher_core::protocol::Origin>,
/// A download waiting to be resumed.
pub paused: Option<PausedDownload>,
}
impl AppEntry {
pub fn new(app: &'static CraftApp) -> Self {
Self { app, latest: None, installed: None, detected: false, job: None, paused: None }
Self { app, latest: None, installed: None, detected: false, job: None, origin: None, paused: None }
}
pub fn release(&self) -> Option<&Release> {

View File

@ -58,22 +58,22 @@ pub enum CheckEvery {
}
impl CheckEvery {
pub const ALL: [Self; 4] = [Self::Hour, Self::FourHours, Self::Day, Self::Never];
/// The choices offered. `FourHours` and `Day` are kept so saved settings still load; they now
/// mean "automatically" too.
pub const ALL: [Self; 2] = [Self::Hour, Self::Never];
pub fn label(self) -> &'static str {
match self {
Self::Hour => "Every hour",
Self::FourHours => "Every 4 hours",
Self::Day => "Once a day",
Self::Hour | Self::FourHours | Self::Day => "Automatically",
Self::Never => "Only when I ask",
}
}
/// How often to look. Checks ask ArtCraft's release feed (one cheap request; GitHub only as a
/// fallback), so automatic checks run every five minutes.
pub fn seconds(self) -> Option<i64> {
match self {
Self::Hour => Some(3600),
Self::FourHours => Some(4 * 3600),
Self::Day => Some(24 * 3600),
Self::Hour | Self::FourHours | Self::Day => Some(5 * 60),
Self::Never => None,
}
}
@ -98,6 +98,8 @@ pub struct Settings {
pub prereleases: bool,
/// Linux: the package format to prefer for new installs (empty: the system's own).
pub preferred_kinds: Vec<PackageKind>,
/// Announcements and alerts from the release feed the user dismissed (by notice id).
pub dismissed_notices: std::collections::BTreeSet<String>,
/// Updates already announced, so each version is announced once: app id → version.
pub notified: BTreeMap<String, String>,
/// Unix seconds of the last finished check.
@ -118,6 +120,7 @@ impl Default for Settings {
notify: true,
prereleases: false,
preferred_kinds: Vec::new(),
dismissed_notices: std::collections::BTreeSet::new(),
notified: BTreeMap::new(),
last_check: None,
}

View File

@ -142,6 +142,7 @@ impl LauncherUi {
});
ui.add_space(14.0);
}
self.announcements(ui);
ui.horizontal(|ui| {
let updates = self.count(Filter::Updates);
let filters: Vec<(Filter, Cell<'_>)> = [
@ -205,6 +206,37 @@ impl LauncherUi {
});
}
/// News from ArtCraft's release feed (and app renames), until the user dismisses each.
fn announcements(&mut self, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let shown: Vec<artcraft_launcher_core::feed::Notice> =
self.announcements.iter().filter(|n| !self.settings.dismissed_notices.contains(&n.id)).take(3).cloned().collect();
for notice in shown {
egui::Frame::new().fill(t.sunken).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(14, 10)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
if widgets::glyph_button(ui, widgets::Glyph::Close, "Dismiss").clicked() {
self.settings.dismissed_notices.insert(notice.id.clone());
}
if let Some(link) = &notice.link {
widgets::hud_link(ui, "Learn more", link);
}
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if !notice.title.is_empty() {
ui.label(RichText::new(&notice.title).family(theme::semibold()).color(t.ink_strong));
}
if !notice.body.is_empty() {
ui.add(egui::Label::new(RichText::new(&notice.body).color(t.muted)).wrap());
}
});
});
});
});
ui.add_space(8.0);
}
}
pub(crate) fn footer(&mut self, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let now = self.backend.now();
@ -212,8 +244,8 @@ impl LauncherUi {
if self.checking {
widgets::hud(ui, "Checking for updates…", t.faint);
} else if let Some(at) = self.settings.last_check {
let via = self.last_source.map(|s| format!(" · {}", s.label())).unwrap_or_default();
widgets::hud(ui, &format!("Checked {}{via}", time::format_ago(at, now)), t.faint);
let using = self.sources_label().map(|s| format!(" · using {s}")).unwrap_or_default();
widgets::hud(ui, &format!("Checked {}{using}", time::format_ago(at, now)), t.faint);
} else {
widgets::hud(ui, "Not checked yet", t.faint);
}

View File

@ -4,7 +4,7 @@
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, PausedDownload, Source};
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, Source};
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::status::InstallSource;
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
@ -79,9 +79,13 @@ fn events() -> Vec<Event> {
("cadcraft", "0.3.0"),
];
for (i, (id, v)) in versions.iter().enumerate() {
ev.push(Event::Latest { app: (*id).into(), latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)) });
ev.push(Event::Latest {
app: (*id).into(),
latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)),
from: Origin::ArtCraftApi,
});
}
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None });
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });
let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")];
for app in &SUITE {
let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed {
@ -460,6 +464,95 @@ fn the_current_desktop_setting_is_only_offered_where_the_os_has_desktops() {
assert!(Settings::default().show_on_current_desktop);
}
#[test]
fn feed_announcements_and_alerts_show_until_dismissed() {
use artcraft_launcher_core::feed::Notice;
let notice = |id: &str, title: &str, link: Option<&str>| Notice {
id: id.into(),
title: title.into(),
body: format!("{title} body"),
link: link.map(str::to_string),
created_at: None,
};
let sent = Rc::new(RefCell::new(Vec::new()));
let mut evs = events();
evs.push(Event::Notices {
announcements: vec![notice("a1", "CADCraft 1.0 is out", Some("https://getartcraft.com/news"))],
alerts: vec![notice("x1", "Security update", Some("https://getartcraft.com/security"))],
});
let mut backend = Some(Fake { events: evs, sent, manage: true, clock: Rc::new(Cell::new(NOW)) });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() }));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
);
h.run_steps(6);
// The alert pops up first; OK acknowledges it for good.
h.get_by_label("Security update");
h.get_by_label("Open link");
h.get_by_label("OK").click();
h.run_steps(4);
assert!(h.query_by_label("Security update").is_none());
assert!(h.state().as_ref().unwrap().settings.dismissed_notices.contains("x1"));
// The announcement stays inline until dismissed.
h.get_by_label("CADCraft 1.0 is out");
h.get_by_label("Learn more");
h.get_all_by_label("Dismiss").next().unwrap().click();
h.run_steps(4);
assert!(h.query_by_label("CADCraft 1.0 is out").is_none());
assert!(h.state().as_ref().unwrap().settings.dismissed_notices.contains("a1"));
}
#[test]
fn automatic_checks_run_every_five_minutes() {
use artcraft_launcher_ui_egui::settings::CheckEvery;
assert_eq!(Settings::default().check_every.seconds(), Some(300));
// Older saved choices still load, and mean "automatically" now.
assert_eq!(CheckEvery::Day.seconds(), Some(300));
assert_eq!(CheckEvery::Never.seconds(), None);
}
#[test]
fn the_footer_names_the_services_behind_what_is_on_screen() {
let footer = |extra: Vec<Event>| {
let mut evs = events();
evs.extend(extra);
let sent = Rc::new(RefCell::new(Vec::new()));
let mut backend = Some(Fake { events: evs, sent, manage: true, clock: Rc::new(Cell::new(NOW)) });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() }));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
);
h.run_steps(6);
let label =
h.get_by_label_contains("Checked ").accesskit_node().label().or_else(|| h.get_by_label_contains("Checked ").accesskit_node().value());
label.unwrap_or_default()
};
assert_eq!(footer(Vec::new()), "Checked 3 min ago · using ArtCraft API");
let from_github =
vec![Event::Latest { app: "wordcraft".into(), latest: LatestRelease::Found(release("wordcraft", "0.3.1", NOW)), from: Origin::GitHub }];
assert_eq!(footer(from_github), "Checked 3 min ago · using ArtCraft API and GitHub");
}
#[test]
fn web_build_offers_downloads() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false);