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
@@ -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");@@ -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ší informace@@ -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 erfahren@@ -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ón@@ -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 plus@@ -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ó@@ -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 詳細を見る@@ -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ęcej@@ -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 mais@@ -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 Подробнее@@ -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 确定@@ -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()@@ -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]