collective

Browse / github.com/storytold/craft-launcher / patch

fix: footer says saved results when no server could be reached (#575)

Footer says "Checked just now · using ArtCraft API and GitHub" while the launcher is offline **ArtCraft Launcher 0.5.1** windows-x64 MSI, Windows 11 25H2 (26200), UI language German (auto). Same code on main (ad7f24a). When no server can be reached, the footer still reports a fresh check and names the services as if they had answered. The user can't tell that the list shows saved results. **Steps** 1. Simulate "no network" for the launcher only: start it with `HTTPS_PROXY=http://127.0.0.1:9` and `HTTP_PROXY=http://127.0.0.1:9` (nothing listens on port 9, every connection is refused). 2. Let the start-up check finish, or press the refresh button. **Expected**: the footer says the c

Status
open
Score
0.00
Adopters
0 other operators
Author
MarekSpodBiedry (imported from pull request #613, open)
Base
7c7271e5f62975326d0add2dcf31f03096be4189
Size
+70 −22 in 13 files
Review
pending
Shared
2026-10-28
Id
111635da0682eaab7fb637fc

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/backend.rs crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-BR.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/zh-CN.tsv crates/ui-egui/src/views.rs crates/ui-egui/tests/ui.rs

diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs--- a/crates/native/src/backend.rs+++ b/crates/native/src/backend.rs@@ -233,12 +233,12 @@ impl NativeBackend {         let mirror = local_mirror();         std::thread::spawn(move || {             shared.emit(Event::CheckStarted);-            let (feed, fallback) = if let Some(base) = &mirror {+            let (feed, fallback, feed_failed) = if let Some(base) = &mirror {                 // A local mirror answers for everything: GitHub is never asked.-                (local_feed(&http, base), Fallback { trust_feed: true, github_every: GITHUB_FALLBACK_EVERY })+                (local_feed(&http, base), Fallback { trust_feed: true, github_every: GITHUB_FALLBACK_EVERY }, false)             } else if pre {                 // The feed serves stable releases only; with pre-releases on, GitHub decides.-                (None, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY })+                (None, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY }, false)             } else {                 feed_and_fallback(&http, &paths, &shared)             };@@ -298,15 +298,7 @@ impl NativeBackend {                 report_partial(app, &shared, &paths, platform, &options);             }             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 if sources.contains(&Source::GitHubApi) {-                Source::GitHubApi-            } else {-                Source::Feed-            };+            let source_used = source_used(&sources, feed_failed);             let at = crate::now();             *shared.last_check.lock().unwrap_or_else(PoisonError::into_inner) = Some(at);             shared.checking.store(false, Ordering::SeqCst);@@ -681,32 +673,32 @@ struct Fallback { /// - 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) {+fn feed_and_fallback(http: &Http, paths: &Paths, shared: &Shared) -> (Option<Feed>, Fallback, bool) {     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 (fresh, answered, failures, feed_failed) = {         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)+                (Some(feed), true, 0, false)             }             FeedFetch::Failed => {                 *failures = failures.saturating_add(1);-                (None, false, *failures)+                (None, false, *failures, true)             }             // Not asked this time: things stand as the last request left them.-            FeedFetch::Held => (None, *failures == 0 && saved.is_some(), *failures),+            FeedFetch::Held => (None, *failures == 0 && saved.is_some(), *failures, *failures > 0),         }     };     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)+    (fresh.or(saved), fallback, feed_failed) }  /// The policy in [`feed_and_fallback`], on its own so it can be tested.@@ -755,6 +747,20 @@ fn latest_for(     } } +/// The one source the footer reports for a check: saved answers if any app had to settle for+/// them, which includes a failing feed (asked this time or held back) with only its saved copy to offer.+fn source_used(sources: &[Source], feed_failed: bool) -> Source {+    if sources.contains(&Source::Cache) || (feed_failed && sources.iter().all(|s| *s == Source::Feed)) {+        Source::Cache+    } else if sources.contains(&Source::GitHubPages) {+        Source::GitHubPages+    } else if sources.contains(&Source::GitHubApi) {+        Source::GitHubApi+    } else {+        Source::Feed+    }+}+ /// Until when the footer says GitHub's limit holds details back: only while it does, that is /// when this check had to settle for the release pages or saved answers. When the feed (or the /// API itself) answered everything, nothing is missing, however recently GitHub said no.@@ -1115,6 +1121,30 @@ mod tests {         (url, hits)     } +    #[test]+    fn a_failed_feed_with_only_saved_answers_reports_saved_results() {+        // Offline: the saved feed answered every app, but nothing was reached this time.+        assert_eq!(source_used(&[Source::Feed, Source::Feed], true), Source::Cache);+        assert_eq!(source_used(&[], true), Source::Cache);+        // Online, or GitHub really answered: the live source is named.+        assert_eq!(source_used(&[Source::Feed, Source::Feed], false), Source::Feed);+        assert_eq!(source_used(&[Source::Feed, Source::GitHubApi], true), Source::GitHubApi);+        assert_eq!(source_used(&[Source::GitHubApi, Source::Cache], false), Source::Cache);+    }++    #[test]+    fn a_feed_held_back_after_failing_still_counts_as_failed() {+        // A refresh soon after the feed failed doesn't ask it again: its saved copy is all there is.+        let dir = tempfile::tempdir().unwrap();+        let paths = Paths::under(dir.path());+        let http = Http::new().unwrap();+        let shared = shared();+        *shared.feed_failures.lock().unwrap() = 1;+        shared.feed_throttle.lock().unwrap().finished(Outcome::Failed { retry_after: Some(600) }, crate::now(), 0.0);+        let (_, _, feed_failed) = feed_and_fallback(&http, &paths, &shared);+        assert!(feed_failed);+    }+     #[test]     fn a_429_stops_the_retries_and_holds_off_the_api() {         let (url, hits) = serve("429 Too Many Requests");diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -110,6 +110,7 @@ 	Hidden Apps	Skryté aplikace 	Apps you hid	Aplikace, které jste skryli 	Checked {ago} · using {sources}	Zkontrolováno {ago} · zdroje: {sources}+	Checked {ago} · showing saved results	Zkontrolováno {ago} · zobrazeny uložené výsledky 	{a} and {b}	{a} a {b} 	Join the Discord	Připojit se na Discord 	Learn more	Další informacediff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -124,6 +124,7 @@ 	Hidden Apps	Ausgeblendete Apps 	Apps you hid	Apps, die du ausgeblendet hast 	Checked {ago} · using {sources}	Zuletzt geprüft: {ago} · über {sources}+	Checked {ago} · showing saved results	Zuletzt geprüft: {ago} · gespeicherte Ergebnisse 	{a} and {b}	{a} und {b} 	Join the Discord	Dem Discord beitreten 	Learn more	Mehr erfahrendiff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -123,6 +123,7 @@ 	Hidden Apps	Aplicaciones ocultas 	Apps you hid	Aplicaciones que ocultaste 	Checked {ago} · using {sources}	Comprobado {ago} · con {sources}+	Checked {ago} · showing saved results	Comprobado {ago} · mostrando resultados guardados 	{a} and {b}	{a} y {b} 	Join the Discord	Únete al Discord 	Learn more	Más informacióndiff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -122,6 +122,7 @@ 	Hidden Apps	Applications masquées 	Apps you hid	Applications que vous avez masquées 	Checked {ago} · using {sources}	Vérifié {ago} · via {sources}+	Checked {ago} · showing saved results	Vérifié {ago} · résultats enregistrés affichés 	{a} and {b}	{a} et {b} 	Join the Discord	Rejoindre le Discord 	Learn more	En savoir plusdiff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -124,6 +124,7 @@ 	Hidden Apps	Rejtett alkalmazások 	Apps you hid	Az általad elrejtett alkalmazások 	Checked {ago} · using {sources}	Ellenőrizve: {ago} · {sources}+	Checked {ago} · showing saved results	Ellenőrizve: {ago} · mentett eredmények láthatók 	{a} and {b}	{a} és {b} 	Join the Discord	Csatlakozás a Discordhoz 	Learn more	További információdiff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -126,6 +126,7 @@ 	Hidden Apps	非表示のアプリ 	Apps you hid	非表示にしたアプリ 	Checked {ago} · using {sources}	最終確認: {ago} · 取得元: {sources}+	Checked {ago} · showing saved results	最終確認: {ago} · 保存済みの結果を表示中 	{a} and {b}	{a} と {b} 	Join the Discord	Discord に参加 	Learn more	詳細を見るdiff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -125,6 +125,7 @@ 	Hidden Apps	Ukryte aplikacje 	Apps you hid	Aplikacje ukryte przez ciebie 	Checked {ago} · using {sources}	Ostatnio sprawdzono {ago} · przy użyciu {sources}+	Checked {ago} · showing saved results	Ostatnio sprawdzono {ago} · pokazano zapisane wyniki 	{a} and {b}	{a} i {b} 	Join the Discord	Dołącz na Discorda 	Learn more	Więcejdiff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv--- a/crates/ui-egui/src/i18n/pt-BR.tsv+++ b/crates/ui-egui/src/i18n/pt-BR.tsv@@ -112,6 +112,7 @@ 	Hidden Apps	Aplicativos ocultos 	Apps you hid	Aplicativos que você ocultou 	Checked {ago} · using {sources}	Verificado {ago} · via {sources}+	Checked {ago} · showing saved results	Verificado {ago} · mostrando resultados salvos 	{a} and {b}	{a} e {b} 	Join the Discord	Entrar no Discord 	Learn more	Saiba maisdiff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -122,6 +122,7 @@ 	Hidden Apps	Скрытые приложения 	Apps you hid	Приложения, которые вы скрыли 	Checked {ago} · using {sources}	Проверено {ago} · через {sources}+	Checked {ago} · showing saved results	Проверено {ago} · показаны сохранённые результаты 	{a} and {b}	{a} и {b} 	Join the Discord	Присоединиться к Discord 	Learn more	Подробнееdiff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv--- a/crates/ui-egui/src/i18n/zh-CN.tsv+++ b/crates/ui-egui/src/i18n/zh-CN.tsv@@ -255,6 +255,7 @@ figcraft	Interface design and prototyping	界面设计与原型制作 	Download cancelled	下载已取消 	Automatically	自动 	Checked {ago} · using {sources}	上次检查:{ago} · 来源:{sources}+	Checked {ago} · showing saved results	上次检查:{ago} · 显示已保存的结果 	{a} and {b}	{a} 和 {b} 	Learn more	了解更多 	OK	确定diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs--- a/crates/ui-egui/src/views.rs+++ b/crates/ui-egui/src/views.rs@@ -3,7 +3,7 @@  use artcraft_launcher_core::details::AppDetails; use artcraft_launcher_core::platform::pick_asset;-use artcraft_launcher_core::protocol::{Command, JobPhase};+use artcraft_launcher_core::protocol::{Command, JobPhase, Source}; use artcraft_launcher_core::status::AppStatus; use artcraft_launcher_core::time; use egui::{Color32, CornerRadius, FontId, Rect, RichText, Sense, Stroke, Ui, vec2};@@ -298,9 +298,14 @@ impl LauncherUi {             tl!("Checking for updates…").to_string()         } else if let Some(at) = self.settings.last_check {             let ago = ago_text(at, now);-            match self.sources_label() {-                Some(using) => fmt(tl!("Checked {ago} · using {sources}"), &[("ago", &ago), ("sources", &using)]),-                None => fmt(tl!("Checked {ago}"), &[("ago", &ago)]),+            // Nothing was reached: the cards show saved answers, whichever service they came from.+            if self.last_source == Some(Source::Cache) {+                fmt(tl!("Checked {ago} · showing saved results"), &[("ago", &ago)])+            } else {+                match self.sources_label() {+                    Some(using) => fmt(tl!("Checked {ago} · using {sources}"), &[("ago", &ago), ("sources", &using)]),+                    None => fmt(tl!("Checked {ago}"), &[("ago", &ago)]),+                }             }         } else {             tl!("Not checked yet").to_string()diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs--- a/crates/ui-egui/tests/ui.rs+++ b/crates/ui-egui/tests/ui.rs@@ -1199,6 +1199,9 @@ fn the_footer_names_the_services_behind_what_is_on_screen() {     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");+    // Nothing could be reached: the cards show saved answers, so no service is named.+    let offline = vec![Event::CheckFinished { at: NOW - 180, source: Source::Cache, rate_limited_until: None }];+    assert_eq!(footer(offline), "Checked 3 min ago · showing saved results"); }  #[test]