Compare commits
4 Commits
main
...
bt-service
| Author | SHA1 | Date |
|---|---|---|
|
|
8c026a9965 | |
|
|
3d92df6423 | |
|
|
b2abc6cac6 | |
|
|
7287ec978e |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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, ¤t)
|
||||
&& !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, ¤t)
|
||||
&& !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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))?;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
pub mod backend;
|
||||
pub mod detect;
|
||||
pub mod feed_throttle;
|
||||
pub mod http;
|
||||
pub mod install;
|
||||
pub mod login;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) = ¬ice.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(¬ice.title).family(theme::semibold()).color(t.ink_strong));
|
||||
}
|
||||
if !notice.body.is_empty() {
|
||||
ui.add(egui::Label::new(RichText::new(¬ice.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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue