diff --git a/apps/artcraft-launcher-web/src/web.rs b/apps/artcraft-launcher-web/src/web.rs index d6a5744..9f9feb8 100644 --- a/apps/artcraft-launcher-web/src/web.rs +++ b/apps/artcraft-launcher-web/src/web.rs @@ -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 diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index c7eadd1..2e6fee7 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -97,6 +97,24 @@ 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 { @@ -127,6 +145,8 @@ 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), diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index 9668d92..4ff9bd5 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -9,7 +9,7 @@ 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}; @@ -23,9 +23,19 @@ use crate::source::{GitHubSource, ReleaseSource}; /// 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). +/// 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), and the waits between them. +const FEED_ATTEMPTS: usize = 3; +const FEED_RETRY_WAITS: [Duration; 2] = [Duration::from_secs(1), Duration::from_secs(3)]; +/// 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; @@ -47,6 +57,14 @@ struct Shared { launcher_release: Mutex>, /// When GitHub was last asked about each app (Unix seconds), to keep the fallback rare. github_asked: Mutex>, + /// Apps with an answer (a release or "none yet") from either service. + answered: Mutex>, + /// 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, + /// When the feed last answered (Unix seconds). + feed_ok_at: Mutex>, } impl Shared { @@ -95,6 +113,10 @@ impl NativeBackend { 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), last_check: Mutex::new(None), }); let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() }; @@ -110,16 +132,16 @@ impl NativeBackend { let feed = if pre { None } else { load_feed(&self.paths) }; let now = crate::now(); for app in &SUITE { - 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)), + 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) = latest { + 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 { @@ -169,8 +191,11 @@ impl NativeBackend { fn check(&self, force: bool) { 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; }; @@ -184,8 +209,12 @@ impl NativeBackend { let pre = self.options().prereleases; std::thread::spawn(move || { shared.emit(Event::CheckStarted); - // The feed serves stable releases only; with pre-releases on, GitHub decides. - let feed = if pre { None } else { fetch_feed(&http, &paths) }; + 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() }); } @@ -197,7 +226,7 @@ impl NativeBackend { scope.spawn(|| { loop { let Some(app) = queue.lock().unwrap_or_else(PoisonError::into_inner).pop() else { break }; - let (latest, used) = latest_for(app, feed.as_ref(), source.as_ref(), &shared, pre, force, now); + 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); } @@ -217,12 +246,21 @@ impl NativeBackend { 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 }); + 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 }); } }); } }); 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 @@ -434,26 +472,81 @@ 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. +/// 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, 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 fresh = fetch_feed(http, paths, attempts); + let now = crate::now(); + let failures = { + let mut failures = shared.feed_failures.lock().unwrap_or_else(PoisonError::into_inner); + if fresh.is_some() { + *failures = 0; + *shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner) = Some(now); + } else { + *failures = failures.saturating_add(1); + } + *failures + }; + let ok_at = *shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner); + let answered = fresh.is_some(); + 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, 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, Option) { - 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 => {} +) -> (Option, Origin, Option) { + 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 >= GITHUB_FALLBACK_EVERY); + 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); } @@ -461,9 +554,9 @@ fn latest_for( }; if due { let answer = source.latest(&repo, pre); - (Some(answer.latest), Some(answer.source)) + (Some(answer.latest), Origin::GitHub, Some(answer.source)) } else { - (source.cached(&repo, pre), None) + (source.cached(&repo, pre), Origin::GitHub, None) } } @@ -472,22 +565,28 @@ 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 { - 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}"); +/// Asks ArtCraft's release feed up to `attempts` times (waiting 1 s, then 3 s, between tries), +/// saving a good answer. `None` when it never answered with something readable. +fn fetch_feed(http: &Http, paths: &Paths, attempts: usize) -> Option { + let url = feed_url(); + for attempt in 0..attempts.max(1) { + if attempt > 0 { + std::thread::sleep(FEED_RETRY_WAITS.get(attempt - 1).copied().unwrap_or(Duration::from_secs(3))); + } + 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}"); + } + return Some(feed); } - return Some(feed); - } - None => log::warn!("the release feed answered with something unreadable"), - }, - Err(e) => log::info!("release feed unavailable, using GitHub: {e}"), + None => log::warn!("the release feed answered with something unreadable (try {})", attempt + 1), + }, + Err(e) => log::info!("release feed unavailable (try {}): {e}", attempt + 1), + } } - load_feed(paths) + None } /// The feed's address. `ARTCRAFT_LAUNCHER_FEED_URL` points at a staging server or a local copy @@ -652,10 +751,16 @@ mod tests { 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), } } 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( @@ -666,6 +771,36 @@ mod tests { .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(); @@ -675,33 +810,33 @@ mod tests { 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); + 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), &github, &shared, false, false, NOW); + 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), &github, &shared, false, false, NOW + 30 * 60); + 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), &github, &shared, false, true, NOW + 31 * 60); + 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), &github, &shared, false, false, NOW + 31 * 60 + GITHUB_FALLBACK_EVERY); + 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, &github, &shared, false, false, NOW); + 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), &github, &fresh_shared, false, false, NOW + 2 * 86_400); + let (_, _, used) = latest_for(photo, Some(&feed), LOADED, &github, &fresh_shared, false, false, NOW + 2 * 86_400); assert_eq!(used, Some(Source::GitHubApi)); } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 65d8d64..1e54253 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -217,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 @@ -443,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 { + 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() diff --git a/crates/ui-egui/src/model.rs b/crates/ui-egui/src/model.rs index e71c840..24e84e9 100644 --- a/crates/ui-egui/src/model.rs +++ b/crates/ui-egui/src/model.rs @@ -14,13 +14,15 @@ pub struct AppEntry { /// Detection has answered at least once. pub detected: bool, pub job: Option, + /// The service `latest` came from. + pub origin: Option, /// A download waiting to be resumed. pub paused: Option, } 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> { diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 4d7a026..dbb7622 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -249,8 +249,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); } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 660d3d2..09623c7 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -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 { ("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 { @@ -517,6 +521,38 @@ fn automatic_checks_run_every_five_minutes() { assert_eq!(CheckEvery::Never.seconds(), None); } +#[test] +fn the_footer_names_the_services_behind_what_is_on_screen() { + let footer = |extra: Vec| { + 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| { + 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);