From eb4e811b522922a894e814e02bf2de3bfbbee1fd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:30:58 +0200 Subject: [PATCH] feat(desktop): portable mode needs a writable DesignCraftData (#574) A portable copy unzipped into a read-only folder such as Program Files kept pointing settings and recovery at a DesignCraftData it could not write, so both were lost without a word. The data folder is now probed (created, then a file written and removed) and, when that fails, the platform folders are used as if there were no portable.txt; the log and the status bar say which folder could not be written. DESIGNCRAFT_CONFIG_DIR still wins. Adds a_portable_install_that_cannot_write_its_data_uses_the_platform_paths. --- README.md | 2 + apps/designcraft/src/main.rs | 10 +++++ apps/designcraft/src/profile.rs | 71 ++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d81ad553..5f37f0e5 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ The logger is `apps/designcraft/src/logging.rs`; the web build logs to the brows A file named `portable.txt` next to the executable (on macOS, next to `DesignCraft.app`) makes the install portable: settings, logs and crash recovery are kept in a `DesignCraftData` folder beside it instead of the per-user folders above. The Windows portable zip ships with one; delete it to go back to `%APPDATA%\DesignCraft`. +If `DesignCraftData` cannot be written (a copy unzipped into Program Files), the per-user folders +are used instead and the log and the status bar say so. `DESIGNCRAFT_CONFIG_DIR` still takes precedence. The code is `apps/designcraft/src/profile.rs`. ### Graphics backend diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 2f90f648..1158a54f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -258,6 +258,12 @@ fn main() -> eframe::Result { _ => files.push(a), } } + // A portable.txt whose DesignCraftData folder cannot be written: the platform folders are used + // instead (see `profile`), and the user is told, in the log and in the status bar. + let portable_note = profile::unwritable_portable_dir().map(|dir| { + log::warn!("portable mode is off: {} cannot be written; settings and recovery use the usual folders", dir.display()); + format!("Portable mode is off: {} cannot be written, so settings are kept in the usual place.", dir.display()) + }); // The log file lives under the settings directory; opened after the arguments, so `--version` // leaves no file behind. Records logged until now are written to it first. if let (Some(logger), Some(dir)) = (logger, profile::config_dir()) { @@ -343,6 +349,10 @@ fn main() -> eframe::Result { app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" })); } load_prefs(&mut app); + // After `load_prefs`, which replaces the UI state and with it the status line. + if let Some(note) = portable_note { + app.status(note); + } app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs index 703d9704..1450e507 100644 --- a/apps/designcraft/src/profile.rs +++ b/apps/designcraft/src/profile.rs @@ -35,12 +35,37 @@ fn root_with(env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> /// `/DesignCraftData` when `dir` holds [`PORTABLE_MARKER`], where `dir` is the folder the /// user sees the app in: the executable's, or the one holding `DesignCraft.app` when the executable /// sits inside a macOS bundle (`DesignCraft.app/Contents/MacOS`, signed, never edited by hand). -/// A folder named like the marker is no marker. +/// A folder named like the marker is no marker. A data folder that cannot be written (a portable +/// copy unzipped into Program Files) is no portable install either: settings and recovery would be +/// lost without a word, so the platform paths are used and [`unwritable_portable_dir`] says why. fn portable_dir(exe_dir: &Path) -> Option { + marked_data_dir(exe_dir).filter(|data| writable(data)) +} + +fn marked_data_dir(exe_dir: &Path) -> Option { let dir = app_bundle_parent(exe_dir).unwrap_or(exe_dir); dir.join(PORTABLE_MARKER).is_file().then(|| dir.join(PORTABLE_DATA_DIR)) } +/// Whether `dir` exists, or can be created, and takes a new file: read-only attributes and +/// permissions are only known by trying. +fn writable(dir: &Path) -> bool { + if std::fs::create_dir_all(dir).is_err() { + return false; + } + let probe = dir.join(format!(".write-test-{}", std::process::id())); + let ok = std::fs::write(&probe, b"").is_ok(); + let _ = std::fs::remove_file(&probe); + ok +} + +/// The data folder of a portable install that could not be used because it is not writable, for +/// the start-up warning. None when `DESIGNCRAFT_CONFIG_DIR` is set: the portable folder is not +/// wanted then. +pub fn unwritable_portable_dir() -> Option { + unwritable_portable_dir_with(|key| std::env::var_os(key), executable_dir().as_deref()) +} + /// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`. fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> { let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?; @@ -48,6 +73,13 @@ fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> { bundle.extension().filter(|ext| *ext == "app").and(bundle.parent()) } +fn unwritable_portable_dir_with(env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> Option { + if env("DESIGNCRAFT_CONFIG_DIR").is_some() { + return None; + } + exe_dir.and_then(marked_data_dir).filter(|data| !writable(data)) +} + fn executable_dir() -> Option { std::env::current_exe().ok()?.parent().map(Path::to_path_buf) } @@ -154,6 +186,43 @@ mod tests { let _ = std::fs::remove_dir_all(&folder); } + /// Unix only: permissions are how a test makes a folder read-only. Root writes through them, + /// so under root there is nothing to show and the test returns. + #[cfg(unix)] + #[test] + fn a_portable_install_that_cannot_write_its_data_uses_the_platform_paths() { + use std::os::unix::fs::PermissionsExt; + let set_mode = |dir: &Path, mode| std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).unwrap(); + // DesignCraftData cannot be created + let exe = install("read-only", |marker| std::fs::write(marker, "").unwrap()); + set_mode(&exe, 0o555); + if std::fs::write(exe.join("root"), "").is_ok() { + eprintln!("running as root: read-only folders are writable, nothing to test"); + set_mode(&exe, 0o755); + let _ = std::fs::remove_dir_all(&exe); + return; + } + assert_eq!(config_dir_with("windows", environment, Some(&exe)), Some("/roaming/DesignCraft".into())); + assert_eq!(recovery_dir_with(root_with(environment, Some(&exe)), || Some("/user/recovery".into())), Some("/user/recovery".into())); + assert_eq!(unwritable_portable_dir_with(environment, Some(&exe)), Some(exe.join("DesignCraftData"))); + set_mode(&exe, 0o755); + // DesignCraftData is there, but nothing can be written in it + let data = exe.join(PORTABLE_DATA_DIR); + std::fs::create_dir(&data).unwrap(); + set_mode(&data, 0o555); + assert_eq!(config_dir_with("windows", environment, Some(&exe)), Some("/roaming/DesignCraft".into())); + assert_eq!(unwritable_portable_dir_with(environment, Some(&exe)), Some(data.clone())); + // the override still wins, so there is nothing to warn about + assert_eq!( + unwritable_portable_dir_with(|key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { None }, Some(&exe)), + None + ); + set_mode(&data, 0o755); + assert_eq!(config_dir_with("windows", environment, Some(&exe)), Some(data)); + assert_eq!(unwritable_portable_dir_with(environment, Some(&exe)), None); + let _ = std::fs::remove_dir_all(&exe); + } + #[test] fn isolated_profile_wins_over_a_portable_install() { let exe = install("override", |marker| std::fs::write(marker, "").unwrap());