Browse / github.com/storytold/craft-launcher / patch
Keep newly discovered releases available while the feed catches up
Keep newly discovered releases available while the feed catches up Fixes a caching bug where the launcher could find a new release on GitHub, then replace it with older feed data on the next poll or restart. Once we’ve found a newer release, we now keep showing it until the feed catches up. Adds regression tests for PhotoCraft 0.13 MSI and Flatpak detection, portable ZIP installs and updates, and portable settings staying with the launcher when its folder moves. Builds on #524, #377 and #756. Tested in a fresh Windows VM. `cargo xtask ci` passed with 397 tests, along with the live download and production feed checks. I also tested WordCraft portable install
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- skylartaylor (imported from pull request #809, open)
- Base
9419d8977bc70f80373da8558356e4ba7387c7b2- Size
- +234 −3 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1e90404ea71f166026f726e9
How it went
No other operator has reported using it yet.
Diff
raw · apps/artcraft-launcher/src/main.rs apps/artcraft-launcher/src/settings_file.rs crates/core/src/platform.rs crates/core/tests/fixtures/photocraft-0.13.0.json crates/native/src/backend.rs crates/native/src/portable.rs
@@ -777,6 +777,12 @@ impl eframe::App for Shell<'_> { #[cfg(test)] mod tests {+ #[test]+ fn eframe_and_tray_use_the_same_settings_file() {+ let file = super::settings_file::path(super::APP_ID).unwrap();+ assert_eq!(super::native_options(false).persistence_path.as_deref(), Some(file.as_path()));+ }+ use std::ptr::NonNull; use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; use std::sync::mpsc::Sender;@@ -80,6 +80,26 @@ mod tests { use super::*; + #[test]+ fn portable_preferences_are_isolated_and_move_with_the_folder() {+ let dir = Scratch::new("portable-isolation");+ let installed = dir.0.join("installed/app.ron");+ let portable = dir.0.join("portable/ArtCraftLauncherData");+ let file = portable_settings(&portable);+ assert_eq!(file, portable.join("data/app.ron"));+ let installed_settings = Settings { notify: false, ..Settings::default() };+ let portable_preferences = Settings { auto_update: true, ..Settings::default() };+ save(&installed, "settings", &installed_settings).unwrap();+ assert_eq!(load::<Settings>(&file, "settings"), None);+ save(&file, "settings", &portable_preferences).unwrap();+ assert_eq!(load::<Settings>(&installed, "settings"), Some(installed_settings));+ let moved = dir.0.join("moved/ArtCraftLauncherData");+ std::fs::create_dir_all(moved.parent().unwrap()).unwrap();+ std::fs::rename(&portable, &moved).unwrap();+ let moved_file = portable_settings(&moved);+ assert_eq!(load::<Settings>(&moved_file, "settings"), Some(portable_preferences));+ }+ /// A file in a folder of its own, removed when the test ends. struct Scratch(PathBuf); @@ -261,6 +261,20 @@ mod tests { "SHA256SUMS.txt", ]; + #[test]+ fn photocraft_013_has_both_reported_windows_and_flatpak_downloads() {+ let release = crate::release::parse_github_release(include_str!("../tests/fixtures/photocraft-0.13.0.json"), "storytold/photocraft").unwrap();+ for (os, kind, name) in [+ (Os::Windows, PackageKind::Msi, "photocraft-0.13.0-windows-x64.msi"),+ (Os::Linux, PackageKind::Flatpak, "photocraft-0.13.0-linux-x86_64.flatpak"),+ ] {+ let (asset, picked) = pick_asset(&release, &["photocraft"], Platform { os, arch: Arch::X64 }, &[kind]).unwrap();+ assert_eq!(asset.name, name);+ assert_eq!(picked, kind);+ assert!(asset.sha256.is_some());+ }+ }+ #[test] fn macos_prefers_the_published_universal_build() { // PhotoCraft v0.9.0's GitHub metadata, trimmed to its desktop DMG.new file mode 100644@@ -0,0 +1,35 @@+{+ "tag_name": "v0.13.0",+ "name": "PhotoCraft v0.13.0",+ "published_at": "2026-10-22T21:37:17Z",+ "html_url": "https://github.com/storytold/photocraft/releases/tag/v0.13.0",+ "draft": false,+ "prerelease": false,+ "body": "",+ "assets": [+ {+ "name": "photocraft-0.13.0-linux-x86_64.flatpak",+ "size": 63814520,+ "browser_download_url": "https://github.com/storytold/photocraft/releases/download/v0.13.0/photocraft-0.13.0-linux-x86_64.flatpak",+ "digest": "sha256:15220b5acdd400d81377efd1df7a3f87fad01ee5afcf5639463e5fc44f8a670a"+ },+ {+ "name": "photocraft-0.13.0-windows-x64-portable.zip",+ "size": 87612081,+ "browser_download_url": "https://github.com/storytold/photocraft/releases/download/v0.13.0/photocraft-0.13.0-windows-x64-portable.zip",+ "digest": "sha256:c766bbab26665937f6f3340b4d4e09e19dfece5b8fd64cf6e580d6dd44303655"+ },+ {+ "name": "photocraft-0.13.0-windows-x64.msi",+ "size": 72855552,+ "browser_download_url": "https://github.com/storytold/photocraft/releases/download/v0.13.0/photocraft-0.13.0-windows-x64.msi",+ "digest": "sha256:394d59361459eb2ff5d7e0aaff4f121c1ae04c17a2d7fb41bc929cc737d5820b"+ },+ {+ "name": "SHA256SUMS.txt",+ "size": 2833,+ "browser_download_url": "https://github.com/storytold/photocraft/releases/download/v0.13.0/SHA256SUMS.txt",+ "digest": "sha256:764206f1143455041cf83e6342e5b02129537f805c459b468b7d68fd1aca28bf"+ }+ ]+}@@ -154,8 +154,11 @@ impl NativeBackend { let now = crate::now(); for app in &SUITE { 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)),+ Some(FeedAnswer::Fresh(found)) => {+ let cached = self.source.as_ref().and_then(|s| s.cached(&feed_repo(feed.as_ref(), app), pre));+ let (latest, from, _) = feed_or_newer_cache(found, cached);+ Some((latest, from))+ } _ => self.source.as_ref().and_then(|s| s.cached(&feed_repo(feed.as_ref(), app), pre)).map(|l| (l, Origin::GitHub)), }; if let Some((latest, from)) = answer {@@ -737,7 +740,8 @@ fn latest_for( // The feed can lag GitHub by some minutes after a release. A check the user pressed // looks past it when GitHub's quota-free redirect names something newer (#91). Some(FeedAnswer::Fresh(found)) if !(force && feed_is_behind(source, &repo, found.as_ref())) => {- return (Some(found.map_or(LatestRelease::None, LatestRelease::Found)), Origin::ArtCraftApi, Some(Source::Feed));+ let (latest, origin, used) = feed_or_newer_cache(found, source.cached(&repo, pre));+ return (Some(latest), origin, Some(used)); } Some(FeedAnswer::Fresh(_) | FeedAnswer::AskGitHub) | None => {} }@@ -758,6 +762,18 @@ fn latest_for( } } +/// A feed can lag a release already discovered on GitHub. Keep the newer cached version on+/// subsequent polls and first paint after restart; an equal/newer feed remains authoritative.+fn feed_or_newer_cache(found: Option<Release>, cached: Option<LatestRelease>) -> (LatestRelease, Origin, Source) {+ if let Some(LatestRelease::Found(cached)) = cached+ && let Some(version) = cached.version.as_ref()+ && found.as_ref().is_none_or(|r| r.version.as_ref().is_some_and(|feed| version::is_newer(version, feed)))+ {+ return (LatestRelease::Found(cached), Origin::GitHub, Source::Cache);+ }+ (found.map_or(LatestRelease::None, LatestRelease::Found), Origin::ArtCraftApi, Source::Feed)+}+ /// Whether GitHub names a newer stable release of `repo` than the feed's `latest` (`None`: the /// feed says there is none). `false` whenever the redirect can't be read: the feed then stands. fn feed_is_behind(source: &dyn ReleaseSource, repo: &str, latest: Option<&Release>) -> bool {@@ -1234,6 +1250,90 @@ mod tests { assert!(is_loopback("http://127.0.0.1:8787") && is_loopback("http://127.0.0.1:8787/release_info.json")); } + struct PublishedPhoto {+ release: Release,+ calls: AtomicUsize,+ }++ impl ReleaseSource for PublishedPhoto {+ fn latest(&self, _: &str, _: bool) -> Answer {+ self.calls.fetch_add(1, Ordering::SeqCst);+ Answer { latest: LatestRelease::Found(self.release.clone()), source: Source::GitHubApi }+ }+ fn cached(&self, _: &str, _: bool) -> Option<LatestRelease> {+ Some(LatestRelease::Found(self.release.clone()))+ }+ fn rate_limited_until(&self) -> Option<i64> {+ None+ }+ fn save(&self) {}+ }++ #[test]+ fn cached_answers_never_replace_equal_or_newer_feed_metadata() {+ let release = |v: &str| Release { tag: format!("v{v}"), version: version::parse(v), ..Release::default() };+ let github = |v| Some(LatestRelease::Found(release(v)));+ for (feed, cached, expected, origin) in [+ (Some(release("0.12.0")), github("0.13.0"), "0.13.0", Origin::GitHub),+ (Some(release("0.13.0")), github("0.12.0"), "0.13.0", Origin::ArtCraftApi),+ (Some(release("0.13.0")), github("0.13.0"), "0.13.0", Origin::ArtCraftApi),+ (Some(release("0.13.0")), Some(LatestRelease::Failed("offline".into())), "0.13.0", Origin::ArtCraftApi),+ (None, github("0.13.0"), "0.13.0", Origin::GitHub),+ ] {+ let (latest, from, _) = feed_or_newer_cache(feed, cached);+ assert!(matches!(latest, LatestRelease::Found(r) if r.version == version::parse(expected)));+ assert_eq!(from, origin);+ }+ assert!(matches!(feed_or_newer_cache(None, None).0, LatestRelease::None));+ }++ #[test]+ fn first_paint_keeps_a_newer_cached_release_after_restart() {+ let dir = tempfile::tempdir().unwrap();+ let release = artcraft_launcher_core::release::parse_github_release(+ include_str!("../../core/tests/fixtures/photocraft-0.13.0.json"),+ "storytold/photocraft",+ )+ .unwrap();+ let github = Arc::new(PublishedPhoto { release, calls: AtomicUsize::new(0) });+ let (year, month, day) = artcraft_launcher_core::time::civil_from_unix(crate::now());+ let now = format!("{year:04}-{month:02}-{day:02}T00:00:00Z");+ let feed = serde_json::json!({"release_info":{"apps":{"photocraft":{+ "repo":"storytold/photocraft", "latest_release":{"tag":"v0.12.0"}, "app_last_fetched":now+ }}}});+ let paths = Paths::under(dir.path());+ crate::paths::write_atomic(&paths.feed_cache(), feed.to_string().as_bytes()).unwrap();+ let mut restarted = backend(dir.path(), Platform { os: Os::Windows, arch: Arch::X64 }, Some(github.clone()));+ restarted.replay_cache();+ assert!(restarted.poll().into_iter().any(|event| matches!(event,+ Event::Latest { app, latest: LatestRelease::Found(r), from: Origin::GitHub } if app == "photocraft" && r.tag == "v0.13.0"+ )));+ assert_eq!(github.calls.load(Ordering::SeqCst), 0);+ }++ #[test]+ fn polling_does_not_hide_a_newer_cached_windows_or_flatpak_release() {+ let release = artcraft_launcher_core::release::parse_github_release(+ include_str!("../../core/tests/fixtures/photocraft-0.13.0.json"),+ "storytold/photocraft",+ )+ .unwrap();+ let github = PublishedPhoto { release, calls: AtomicUsize::new(0) };+ let app = CraftApp::by_id("photocraft").unwrap();+ // Repeated automatic polls must retain the published version without another GitHub request.+ let state = shared();+ for _ in 0..2 {+ let (latest, origin, _) = latest_for(app, Some(&feed()), LOADED, &github, &state, false, false, NOW);+ let Some(LatestRelease::Found(release)) = latest else { panic!("release missing") };+ assert_eq!(release.tag, "v0.13.0");+ assert_eq!(origin, Origin::GitHub);+ for (os, kind) in [(Os::Windows, PackageKind::Msi), (Os::Linux, PackageKind::Flatpak)] {+ assert!(pick_asset(&release, &["photocraft"], Platform { os, arch: Arch::X64 }, &[kind]).is_some());+ }+ }+ assert_eq!(github.calls.load(Ordering::SeqCst), 0);+ }+ #[test] fn github_is_only_the_fallback_and_is_asked_rarely() { let github = CountingGitHub::default();@@ -695,6 +695,62 @@ mod tests { assert_eq!(std::fs::read(foreign_root.join("photocraft.exe")).unwrap(), b"foreign program"); } + #[test]+ fn versioned_release_folders_install_update_and_remove_without_touching_user_files() {+ let dir = tempfile::tempdir().unwrap();+ let app = CraftApp::by_id("wordcraft").unwrap();+ let paths = Paths::under(dir.path());+ let parent = dir.path().join("My Portable Apps");+ // The layout shipped by WordCraft 0.10.0, including files beside the GUI executable.+ let first = archive(+ dir.path(),+ "wordcraft.zip",+ &[+ ("wordcraft-0.10.0-windows-x64-portable/wordcraft.exe", b"first"),+ ("wordcraft-0.10.0-windows-x64-portable/wordcraft-cli.exe", b"cli"),+ ("wordcraft-0.10.0-windows-x64-portable/lib/runtime.dll", b"runtime"),+ ("wordcraft-0.10.0-windows-x64-portable/obsolete.txt", b"old"),+ ],+ );+ let old = install(app, &first, "0.10.0", None, &paths, Some(&parent), false, &mut |_| Ok(())).unwrap();+ let root = std::fs::canonicalize(parent.join("wordcraft")).unwrap();+ assert_eq!(old.path, root.join("wordcraft.exe"));+ assert_eq!(std::fs::read(root.join("lib/runtime.dll")).unwrap(), b"runtime");+ assert_eq!(std::fs::read(root.join("wordcraft-cli.exe")).unwrap(), b"cli");+ assert!(!root.join("wordcraft-0.10.0-windows-x64-portable").exists());+ let personal = root.join("my-document.docx");+ std::fs::write(&personal, b"personal document").unwrap();+ let next = archive(+ dir.path(),+ "next.zip",+ &[+ ("wordcraft-0.11.0-windows-x64-portable/wordcraft.exe", b"next"),+ ("wordcraft-0.11.0-windows-x64-portable/lib/runtime.dll", b"next runtime"),+ ],+ );+ let record = install(app, &next, "0.11.0", Some(&old), &paths, None, false, &mut |_| Ok(())).unwrap();+ assert_eq!(record.path, old.path);+ assert_eq!(std::fs::read(&record.path).unwrap(), b"next");+ assert!(!root.join("obsolete.txt").exists());+ assert!(!root.join("wordcraft-cli.exe").exists());+ assert_eq!(std::fs::read(&personal).unwrap(), b"personal document");+ assert!(record.files.iter().all(|file| file.starts_with(&root)));+ remove(app, &record).unwrap();+ assert!(!record.path.exists());+ assert!(!root.join("lib/runtime.dll").exists());+ assert_eq!(std::fs::read(personal).unwrap(), b"personal document");+ }++ #[test]+ fn a_wrapped_executable_with_unrelated_top_level_files_is_not_flattened() {+ let dir = tempfile::tempdir().unwrap();+ let app = CraftApp::by_id("wordcraft").unwrap();+ let paths = Paths::under(dir.path());+ let zip = archive(dir.path(), "mixed.zip", &[("one/wordcraft.exe", b"one"), ("two/settings.json", b"foreign")]);+ assert!(install(app, &zip, "0.10.0", None, &paths, None, false, &mut |_| Ok(())).is_err());+ assert!(!paths.apps().join("wordcraft").exists());+ }+ #[test] fn upgrade_downgrade_and_uninstall_preserve_personal_files_and_location() { let dir = tempfile::tempdir().unwrap();