diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs
--- a/apps/artcraft-launcher/src/main.rs
+++ b/apps/artcraft-launcher/src/main.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;
diff --git a/apps/artcraft-launcher/src/settings_file.rs b/apps/artcraft-launcher/src/settings_file.rs
--- a/apps/artcraft-launcher/src/settings_file.rs
+++ b/apps/artcraft-launcher/src/settings_file.rs
@@ -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);
 
diff --git a/crates/core/src/platform.rs b/crates/core/src/platform.rs
--- a/crates/core/src/platform.rs
+++ b/crates/core/src/platform.rs
@@ -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.
diff --git a/crates/core/tests/fixtures/photocraft-0.13.0.json b/crates/core/tests/fixtures/photocraft-0.13.0.json
new file mode 100644
--- /dev/null
+++ b/crates/core/tests/fixtures/photocraft-0.13.0.json
@@ -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"
+    }
+  ]
+}
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
@@ -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();
diff --git a/crates/native/src/portable.rs b/crates/native/src/portable.rs
--- a/crates/native/src/portable.rs
+++ b/crates/native/src/portable.rs
@@ -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();
