Use ArtCraft's release feed; GitHub only as the fallback
The launcher now asks storyteller-web (GET https://api.storyteller.ai/v1/craft_apps/release_info), which polls GitHub for every Craft App on a schedule, instead of polling GitHub itself. - At launch and every five minutes: one request (10 s timeout) for every app's latest release, plus renames and notices - Per app, GitHub is asked only when the feed can't vouch for it: the app isn't listed, its entry is more than 24 hours old, or the feed didn't answer. Even then, at most once an hour per app (Refresh asks at once). With pre-releases on, GitHub decides (the feed serves stable releases) - Read leniently (crates/core/src/feed.rs): every field optional, unknown fields ignored, wrong types read as missing, one bad entry never spoils the rest. Download links must still point at the app's own GitHub releases; a moved repo counts only when the feed's repo_renames says so - The last good answer is saved for an instant first paint and stands in (while still fresh) when the feed can't be reached - Announcements show inline until dismissed; alerts pop up until the user presses OK (with "Open link" for web links); app renames show as announcements; dismissals persist - "Check for updates": Automatically (every five minutes) or Only when I ask; older saved choices load as Automatically - ARTCRAFT_LAUNCHER_FEED_URL points at a staging server or a local copy Tests: the feed parser against hostile input, renames and notices; a contract test against a response produced by storyteller-web's own code; the feed-first rule with a counting fake GitHub; the notices UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2f3b843ef0
commit
4a86071159
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ pub const CANCELLED: &str = "cancelled";
|
|||
/// 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 +113,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",
|
||||
|
|
@ -129,6 +132,11 @@ pub enum Event {
|
|||
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,7 +5,8 @@ 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,
|
||||
|
|
@ -19,8 +20,12 @@ 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;
|
||||
/// When the release feed can't answer for an app, GitHub is asked about it at most this often
|
||||
/// (pressing Refresh asks right away).
|
||||
const GITHUB_FALLBACK_EVERY: i64 = 60 * 60;
|
||||
/// Requests in flight at once during a check.
|
||||
const WORKERS: usize = 4;
|
||||
|
||||
|
|
@ -40,6 +45,8 @@ 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>>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
|
|
@ -87,6 +94,7 @@ impl NativeBackend {
|
|||
checking: AtomicBool::new(false),
|
||||
detecting: AtomicBool::new(false),
|
||||
launcher_release: Mutex::new(None),
|
||||
github_asked: Mutex::new(HashMap::new()),
|
||||
last_check: Mutex::new(None),
|
||||
});
|
||||
let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() };
|
||||
|
|
@ -95,18 +103,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 latest = match feed.as_ref().map(|f| f.answer(app, now)) {
|
||||
Some(FeedAnswer::Fresh(Some(r))) => Some(LatestRelease::Found(r)),
|
||||
Some(FeedAnswer::Fresh(None)) => Some(LatestRelease::None),
|
||||
_ => self.source.as_ref().and_then(|s| s.cached(&feed_repo(feed.as_ref(), app), pre)),
|
||||
};
|
||||
if let Some(latest) = latest {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
if let Some(f) = feed {
|
||||
self.shared.emit(Event::Notices { announcements: f.announcements, alerts: f.alerts });
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> BackendOptions {
|
||||
|
|
@ -145,8 +163,11 @@ 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()) });
|
||||
|
|
@ -163,40 +184,54 @@ impl NativeBackend {
|
|||
let pre = self.options().prereleases;
|
||||
std::thread::spawn(move || {
|
||||
shared.emit(Event::CheckStarted);
|
||||
let queue = Mutex::new(SUITE.iter().collect::<Vec<&CraftApp>>());
|
||||
// The feed serves stable releases only; with pre-releases on, GitHub decides.
|
||||
let feed = if pre { None } else { fetch_feed(&http, &paths) };
|
||||
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, used) = latest_for(app, feed.as_ref(), 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 });
|
||||
shared.emit(Event::Latest { app: app.id.to_string(), latest });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// 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 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 +434,76 @@ fn partial_for(app: &CraftApp, shared: &Shared, paths: &Paths, platform: Option<
|
|||
Some((asset, done))
|
||||
}
|
||||
|
||||
/// The latest release of `app`, and where it came from: the feed when it vouches for the app,
|
||||
/// else GitHub (if it's due), else GitHub's last answer. `None` when there's nothing to say yet.
|
||||
fn latest_for(
|
||||
app: &CraftApp,
|
||||
feed: Option<&Feed>,
|
||||
source: &dyn ReleaseSource,
|
||||
shared: &Shared,
|
||||
pre: bool,
|
||||
force: bool,
|
||||
now: i64,
|
||||
) -> (Option<LatestRelease>, Option<Source>) {
|
||||
match feed.map(|f| f.answer(app, now)) {
|
||||
Some(FeedAnswer::Fresh(Some(r))) => return (Some(LatestRelease::Found(r)), Some(Source::Feed)),
|
||||
Some(FeedAnswer::Fresh(None)) => return (Some(LatestRelease::None), 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 >= GITHUB_FALLBACK_EVERY);
|
||||
if due {
|
||||
asked.insert(app.id.to_string(), now);
|
||||
}
|
||||
due
|
||||
};
|
||||
if due {
|
||||
let answer = source.latest(&repo, pre);
|
||||
(Some(answer.latest), Some(answer.source))
|
||||
} else {
|
||||
(source.cached(&repo, pre), 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))
|
||||
}
|
||||
|
||||
/// Asks ArtCraft's release feed, saving a good answer. When it can't be reached, the last saved
|
||||
/// answer stands in: its entries still only count while they're less than a day old.
|
||||
fn fetch_feed(http: &Http, paths: &Paths) -> Option<Feed> {
|
||||
match http.get_feed(&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}");
|
||||
}
|
||||
return Some(feed);
|
||||
}
|
||||
None => log::warn!("the release feed answered with something unreadable"),
|
||||
},
|
||||
Err(e) => log::info!("release feed unavailable, using GitHub: {e}"),
|
||||
}
|
||||
load_feed(paths)
|
||||
}
|
||||
|
||||
/// 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 +610,98 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
|
||||
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 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), &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), &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), &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), &github, &shared, false, true, NOW + 31 * 60);
|
||||
assert_eq!(github.0.load(Ordering::SeqCst), 2);
|
||||
latest_for(word, Some(&feed), &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, &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), &github, &fresh_shared, false, false, NOW + 2 * 86_400);
|
||||
assert_eq!(used, Some(Source::GitHubApi));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ 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>,
|
||||
|
|
@ -48,6 +50,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 +80,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 +120,15 @@ 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, String> {
|
||||
let mut resp = self.feed.get(url).header("Accept", "application/json").call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;
|
||||
match resp.status().as_u16() {
|
||||
200 => resp.body_mut().with_config().limit(MAX_SMALL).read_to_string().map_err(|e| format!("unreadable answer ({e})")),
|
||||
s => Err(format!("{} answered HTTP {s}", host(url))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)))?;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ 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")
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -239,6 +245,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;
|
||||
|
|
|
|||
|
|
@ -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,37 @@ 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.surface).stroke(egui::Stroke::new(1.0, t.border_strong)).corner_radius(theme::RADIUS + 4).inner_margin(22),
|
||||
);
|
||||
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.text));
|
||||
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.text_muted)).wrap());
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ok = widgets::primary(ui, "OK", t.accent, 90.0).clicked();
|
||||
if let Some(link) = &alert.link
|
||||
&& widgets::secondary(ui, "Open link", 0.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| {
|
||||
|
|
|
|||
|
|
@ -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,42 @@ 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.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(theme::RADIUS)
|
||||
.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::small_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.text));
|
||||
}
|
||||
if !notice.body.is_empty() {
|
||||
ui.add(egui::Label::new(RichText::new(¬ice.body).color(t.text_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();
|
||||
|
|
|
|||
|
|
@ -460,6 +460,63 @@ 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 web_build_offers_downloads() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false);
|
||||
|
|
|
|||
Loading…
Reference in New Issue