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
@@ -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:@@ -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");@@ -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")