collective

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

Settings: one folder for the launcher's files, portable installs included

Settings: one folder for the launcher's files, portable installs included Part of #775 (reported against SoundCraft, but this part is the launcher's). That report notes two folders for one app, which is exactly what the code does: ``` ~/.local/share/artcraft-launcher/ logs, installed.json, apps/ ~/.local/share/ai.storyteller.artcraft-launcher/ app.ron ``` `Paths::new` joins `DIR` (`"artcraft-launcher"` off macOS and Windows); eframe picks its own folder from the app id, `ai.storyteller.artcraft-launcher`. Two independent derivations, so the launcher's state ends up split. ### The part that is more than tidiness **A portable install is not p

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #669, open)
Base
7c7271e5f62975326d0add2dcf31f03096be4189
Size
+110 −6 in 3 files
Review
pending
Shared
2026-10-28
Id
a430edb4169e1e27dbf67f89

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/native/src/paths.rs

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@@ -84,7 +84,13 @@ fn main() -> ExitCode {     let _ = motion::reduced();      let backend_waker = waker.clone();-    let settings_path = settings_file::path(APP_ID);+    // The settings live with the launcher's other files, not in the folder eframe picks from the+    // app id: one folder instead of two, and a portable install keeps them beside its executable+    // (#775). Settings an earlier version left in eframe's folder are adopted once.+    let settings_path = artcraft_launcher_native::paths::Paths::new().ok().map(|p| p.settings());+    if let Some(path) = &settings_path {+        settings_file::adopt(path, settings_file::legacy_path(APP_ID).as_deref());+    }     let mut launcher = Launcher::new(Box::new(NativeBackend::new(move || backend_waker.wake())), starts, settings_path.clone());     #[cfg(target_os = "linux")]     {@@ -107,7 +113,7 @@ fn main() -> ExitCode {                 let (waker, launcher) = (waker.clone(), &mut launcher);                 eframe::run_native(                     "ArtCraft Launcher",-                    native_options(hidden),+                    native_options(hidden, settings_path.clone()),                     Box::new(move |cc| {                         launcher.open(cc, &waker);                         Ok(Box::new(Shell::new(cc, launcher, hidden)))@@ -131,7 +137,10 @@ fn main() -> ExitCode {     } } -fn native_options(hidden: bool) -> eframe::NativeOptions {+/// `settings` is where eframe keeps its state; `None` leaves it the folder eframe picks from the+/// app id. Despite what `NativeOptions::persistence_path` says, it is the file and not its folder:+/// eframe passes it to `FileStorage::from_ron_filepath`.+fn native_options(hidden: bool, settings: Option<std::path::PathBuf>) -> eframe::NativeOptions {     let icon =         eframe::icon_data::from_png_bytes(include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png")).ok();     let mut viewport = egui::ViewportBuilder::default()@@ -154,7 +163,7 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {     } else if let Some(icon) = icon {         viewport = viewport.with_icon(icon);     }-    eframe::NativeOptions { viewport, persist_window: true, wgpu_options: wgpu_options(), ..Default::default() }+    eframe::NativeOptions { viewport, persist_window: true, persistence_path: settings, wgpu_options: wgpu_options(), ..Default::default() } }  /// wgpu's backend list. On Windows that is Direct3D 12 with GL as the fallback, not Vulkan: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@@ -8,11 +8,45 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -/// Where eframe keeps its state for `app_id`, as it picks it for a window with that app id.-pub fn path(app_id: &str) -> Option<PathBuf> {+/// Where eframe kept its state before it was told otherwise: the folder it picks from the app id.+/// Only to read a file left there by an earlier version (see [`adopt`]).+pub fn legacy_path(app_id: &str) -> Option<PathBuf> {     eframe::storage_dir(app_id).map(|dir| dir.join("app.ron")) } +/// Move the settings an earlier version left in eframe's own folder to `path`, once. Does nothing+/// when there is nothing to move or `path` already exists, so a later run never overwrites newer+/// settings with older ones. The old file is left where it is: an older launcher started again+/// still finds it, and it is small.+///+/// Written through a temporary file in the destination folder, as every other write to this file+/// is. A copy that created `path` and then failed would leave a half-written file that the next+/// start takes for adopted settings, and the launcher would read its defaults from then on with+/// the real settings still sitting in the old folder.+pub fn adopt(path: &Path, legacy: Option<&Path>) -> bool {+    let Some(legacy) = legacy else { return false };+    if path.exists() || !legacy.is_file() || legacy == path {+        return false;+    }+    let bytes = match std::fs::read(legacy) {+        Ok(bytes) => bytes,+        Err(e) => {+            log::warn!("{}: {e}", legacy.display());+            return false;+        }+    };+    match artcraft_launcher_native::paths::write_atomic(path, &bytes) {+        Ok(()) => {+            log::info!("adopted the settings from {}", legacy.display());+            true+        }+        Err(e) => {+            log::warn!("{} -> {}: {e}", legacy.display(), path.display());+            false+        }+    }+}+ /// The value stored under `key`, if the file has one that still reads. pub fn load<T: serde::de::DeserializeOwned>(path: &Path, key: &str) -> Option<T> {     eframe::get_value(&RonFile::read(path), key)@@ -127,6 +161,59 @@ mod tests {         assert_eq!(std::fs::read(&tmp).unwrap(), b"another writer's data");     } +    /// Settings an earlier version left in eframe's own folder are taken over once, and never+    /// over newer ones (#775).+    #[test]+    fn settings_left_in_eframes_folder_are_adopted_once() {+        let old = Scratch::new("settings-adopt-old");+        let new = Scratch::new("settings-adopt-new");+        let was = Settings { notify: false, ..Settings::default() };+        std::fs::create_dir_all(&old.0).unwrap();+        save(&old.file(), "settings", &was).unwrap();++        // Nothing where the launcher now keeps them: the old file is taken over.+        assert!(adopt(&new.file(), Some(&old.file())));+        assert_eq!(load::<Settings>(&new.file(), "settings"), Some(was.clone()));+        // The old file stays, so an older launcher started again still finds its settings.+        assert!(old.file().is_file());++        // Already there: left alone, however old the other file is.+        let now = Settings { notify: true, ..Settings::default() };+        save(&new.file(), "settings", &now).unwrap();+        assert!(!adopt(&new.file(), Some(&old.file())));+        assert_eq!(load::<Settings>(&new.file(), "settings"), Some(now));++        // A destination that cannot be written leaves nothing behind for the next start to take+        // for adopted settings: here the folder's place is taken by a file. (This holds however+        // the bytes are written -- the partial-copy case it guards against needs a write that+        // fails after creating the file, which no portable test can provoke.)+        let blocked = Scratch::new("settings-adopt-blocked");+        std::fs::create_dir_all(&blocked.0).unwrap();+        let wall = blocked.0.join("wall");+        std::fs::write(&wall, b"not a folder").unwrap();+        let under = wall.join("app.ron");+        assert!(!adopt(&under, Some(&old.file())));+        assert!(!under.exists(), "no half-written settings file");+        assert_eq!(std::fs::read(&wall).unwrap(), b"not a folder", "and nothing else was replaced");++        // Nothing to take over, and the same file twice, are both no-ops rather than errors.+        let empty = Scratch::new("settings-adopt-empty");+        assert!(!adopt(&empty.file(), Some(&Scratch::new("settings-adopt-absent").file())));+        assert!(!adopt(&empty.file(), None));+        assert!(!adopt(&old.file(), Some(&old.file())));+    }++    /// The settings sit with the launcher's other files, so a portable install keeps them beside+    /// its executable rather than in the user's profile (#775).+    #[test]+    fn the_settings_file_is_in_the_launchers_own_folder() {+        let root = std::env::temp_dir().join(format!("artcraft-launcher-portable-{}", std::process::id()));+        let paths = artcraft_launcher_native::paths::Paths::under(&root);+        assert_eq!(paths.settings(), paths.data.join("app.ron"));+        assert!(paths.settings().starts_with(&root), "{:?}", paths.settings());+        assert!(paths.settings().starts_with(paths.logs().parent().unwrap()), "beside the logs");+    }+     #[test]     fn a_missing_or_broken_file_has_no_settings() {         let dir = Scratch::new("settings-broken");diff --git a/crates/native/src/paths.rs b/crates/native/src/paths.rs--- a/crates/native/src/paths.rs+++ b/crates/native/src/paths.rs@@ -62,6 +62,14 @@ impl Paths {         self.data.join("logs")     } +    /// eframe's state file: the window's size and place, egui's memory and the launcher's+    /// settings. It lives in this folder rather than the one eframe picks from the app id, so the+    /// launcher keeps its files in one place and a portable install keeps its settings beside its+    /// executable instead of in the user's profile (#775).+    pub fn settings(&self) -> PathBuf {+        self.data.join("app.ron")+    }+     /// Where tarball installs (FreeBSD, Linux without AppImage) are unpacked: `<data>/apps/<app>`.     pub fn apps(&self) -> PathBuf {         self.data.join("apps")