From bb30c98f150b47164f4cad7e280b9cdbfbf17518 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:37:45 +0200 Subject: [PATCH 1/3] feat(desktop): portable mode, portable.txt next to the executable (#574) A file named portable.txt beside the executable keeps settings, logs, the GPU start-up record and crash recovery in a DesignCraftData folder next to it instead of the per-user folders, as PhotoCraft and FilmCraft do. A folder of that name is not a marker. DESIGNCRAFT_CONFIG_DIR still wins, empty or not. The Windows portable zip now ships portable.txt; the MSI and the macOS app bundle don't, so nothing changes there. --- README.md | 7 +++ apps/designcraft/src/profile.rs | 98 +++++++++++++++++++++++++++++---- docs/control-protocol.md | 3 +- packaging/windows/package.ps1 | 8 ++- 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c32d0b6ca..2c70044cc 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,13 @@ The file stops growing at 16 MiB. `--version` writes no file. The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. +### Portable mode + +A file named `portable.txt` next to the executable 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`. +`DESIGNCRAFT_CONFIG_DIR` still takes precedence. The code is `apps/designcraft/src/profile.rs`. + ### Graphics backend The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu. A diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs index 3e90879e3..b5f430845 100644 --- a/apps/designcraft/src/profile.rs +++ b/apps/designcraft/src/profile.rs @@ -1,14 +1,19 @@ -//! Native settings and recovery paths, including isolated automation profiles. +//! Native settings and recovery paths, including isolated automation profiles and portable installs. use std::ffi::OsString; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +/// A file with this name next to the executable makes the install portable: settings, logs and +/// recovery data are kept in [`PORTABLE_DATA_DIR`] beside it. The Windows portable zip ships one. +const PORTABLE_MARKER: &str = "portable.txt"; +const PORTABLE_DATA_DIR: &str = "DesignCraftData"; pub fn config_dir() -> Option { - config_dir_with(std::env::consts::OS, |key| std::env::var_os(key)) + config_dir_with(std::env::consts::OS, |key| std::env::var_os(key), executable_dir().as_deref()) } -fn config_dir_with(os: &str, env: impl Fn(&str) -> Option) -> Option { - if let Some(root) = env("DESIGNCRAFT_CONFIG_DIR") { +fn config_dir_with(os: &str, env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> Option { + if let Some(root) = root_with(&env, exe_dir) { // An explicitly empty override must not fall back to the user's profile. return (!root.is_empty()).then(|| PathBuf::from(root)); } @@ -21,8 +26,25 @@ fn config_dir_with(os: &str, env: impl Fn(&str) -> Option) -> Option

Option, exe_dir: Option<&Path>) -> Option { + env("DESIGNCRAFT_CONFIG_DIR").or_else(|| exe_dir.and_then(portable_dir).map(PathBuf::into_os_string)) +} + +/// `/DesignCraftData` when `exe_dir` holds [`PORTABLE_MARKER`]. A folder of that name is +/// no marker. On macOS `exe_dir` is `DesignCraft.app/Contents/MacOS`, which ships none. +fn portable_dir(exe_dir: &Path) -> Option { + exe_dir.join(PORTABLE_MARKER).is_file().then(|| exe_dir.join(PORTABLE_DATA_DIR)) +} + +fn executable_dir() -> Option { + std::env::current_exe().ok()?.parent().map(Path::to_path_buf) +} + pub fn recovery_dir() -> Option { - recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).and_then(own_run) + recovery_dir_with(root_with(|key| std::env::var_os(key), executable_dir().as_deref()), designcraft_engine::recovery::default_dir) + .and_then(own_run) } /// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so @@ -60,7 +82,7 @@ mod tests { #[test] fn isolated_profile_overrides_every_platform_and_recovery() { for os in ["macos", "windows", "linux"] { - let dir = config_dir_with(os, |key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }); + let dir = config_dir_with(os, |key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }, None); assert_eq!(dir, Some(PathBuf::from("/isolated"))); } assert_eq!(recovery_dir_with(Some("/isolated".into()), || Some("/user/recovery".into())), Some(PathBuf::from("/isolated/Recovery"))); @@ -68,16 +90,68 @@ mod tests { #[test] fn empty_override_never_reads_or_writes_the_default_profile() { - assert_eq!(config_dir_with("macos", |key| { if key == "DESIGNCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) } }), None); + assert_eq!( + config_dir_with("macos", |key| { if key == "DESIGNCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) } }, None), + None + ); assert_eq!(recovery_dir_with(Some(OsString::new()), || Some("/user/recovery".into())), None); } + /// A folder standing in for the executable's; `marker` puts `portable.txt` in it, or not. + fn install(name: &str, marker: impl FnOnce(&Path)) -> PathBuf { + let dir = std::env::temp_dir().join(format!("designcraft-profile-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + marker(&dir.join(PORTABLE_MARKER)); + dir + } + + #[test] + fn portable_marker_keeps_settings_and_recovery_next_to_the_executable() { + let exe = install("portable", |marker| std::fs::write(marker, "").unwrap()); + for os in ["macos", "windows", "linux"] { + assert_eq!(config_dir_with(os, environment, Some(&exe)), Some(exe.join("DesignCraftData"))); + } + assert_eq!( + recovery_dir_with(root_with(environment, Some(&exe)), || Some("/user/recovery".into())), + Some(exe.join("DesignCraftData/Recovery")) + ); + let _ = std::fs::remove_dir_all(&exe); + } + + #[test] + fn without_a_marker_file_the_platform_paths_stay() { + let plain = install("plain", |_| {}); + let folder = install("marker-folder", |marker| std::fs::create_dir(marker).unwrap()); + for exe in [&plain, &folder] { + assert_eq!(config_dir_with("windows", environment, Some(exe)), Some("/roaming/DesignCraft".into())); + assert_eq!(recovery_dir_with(root_with(environment, Some(exe)), || Some("/existing/recovery".into())), Some("/existing/recovery".into())); + } + let _ = std::fs::remove_dir_all(&plain); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn isolated_profile_wins_over_a_portable_install() { + let exe = install("override", |marker| std::fs::write(marker, "").unwrap()); + let isolated = |key: &str| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }; + assert_eq!(config_dir_with("windows", isolated, Some(&exe)), Some("/isolated".into())); + assert_eq!(recovery_dir_with(root_with(isolated, Some(&exe)), || None), Some("/isolated/Recovery".into())); + let empty = |key: &str| if key == "DESIGNCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) }; + assert_eq!(config_dir_with("windows", empty, Some(&exe)), None); + assert_eq!(recovery_dir_with(root_with(empty, Some(&exe)), || Some("/user/recovery".into())), None); + let _ = std::fs::remove_dir_all(&exe); + } + #[test] fn unset_override_preserves_existing_platform_paths() { - assert_eq!(config_dir_with("macos", environment), Some("/user/Library/Application Support/DesignCraft".into())); - assert_eq!(config_dir_with("windows", environment), Some("/roaming/DesignCraft".into())); - assert_eq!(config_dir_with("linux", environment), Some("/xdg-config/designcraft".into())); - assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into())); + assert_eq!(config_dir_with("macos", environment, None), Some("/user/Library/Application Support/DesignCraft".into())); + assert_eq!(config_dir_with("windows", environment, None), Some("/roaming/DesignCraft".into())); + assert_eq!(config_dir_with("linux", environment, None), Some("/xdg-config/designcraft".into())); + assert_eq!( + config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }, None), + Some("/user/.config/designcraft".into()) + ); assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into())); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 25136a807..e201d26d5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -5,7 +5,8 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The serve For an isolated native test profile, set `DESIGNCRAFT_CONFIG_DIR` to a dedicated directory before launch. UI/engine preferences and the `Recovery` directory then live under that root. An unset -override preserves the usual platform paths; an explicitly empty override disables those paths. +override preserves the usual platform paths (or `DesignCraftData` next to the executable when a +`portable.txt` sits there); an explicitly empty override disables those paths. `DESIGNCRAFT_NO_PREFS` still disables preference reads/writes independently of recovery. Control pointer and key input is queued across native frames. After a drag, wait for both the diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 7ca2ec146..32bb7c3b0 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): designcraft--windows-.msi per-machine installer (WiX v5) - designcraft--windows--portable.zip designcraft.exe + designcraft-cli.exe + designcraft--windows--portable.zip designcraft.exe + designcraft-cli.exe + portable.txt The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a @@ -106,6 +106,12 @@ $Portable = Join-Path $TargetDir "windows-package\designcraft-$Version-windows-$ Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable +# The marker that makes DesignCraft keep its data in .\DesignCraftData next to the exe instead of +# %APPDATA% (#574; see apps/designcraft/src/profile.rs). +Set-Content -Encoding ascii -Path (Join-Path $Portable 'portable.txt') -Value @( + 'DesignCraft portable: preferences, logs and crash recovery are kept in the DesignCraftData folder', + 'next to designcraft.exe. Delete this file to use %APPDATA%\DesignCraft instead.' +) foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } From 960982810b28c170fa279e0964be9910d71f905c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:09:02 +0200 Subject: [PATCH 2/3] feat(desktop): portable mode looks next to DesignCraft.app on macOS (#574) Inside a macOS bundle the executable sits in DesignCraft.app/Contents/MacOS, a signed folder nobody edits, so the marker and DesignCraftData are looked for next to the .app, as craft-settings does. Adds inside_a_macos_bundle_the_marker_sits_next_to_the_app. --- README.md | 2 +- apps/designcraft/src/profile.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c70044cc..d81ad553c 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ The logger is `apps/designcraft/src/logging.rs`; the web build logs to the brows ### Portable mode -A file named `portable.txt` next to the executable makes the install portable: settings, logs and +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`. `DESIGNCRAFT_CONFIG_DIR` still takes precedence. The code is `apps/designcraft/src/profile.rs`. diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs index b5f430845..703d97041 100644 --- a/apps/designcraft/src/profile.rs +++ b/apps/designcraft/src/profile.rs @@ -32,10 +32,20 @@ fn root_with(env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> env("DESIGNCRAFT_CONFIG_DIR").or_else(|| exe_dir.and_then(portable_dir).map(PathBuf::into_os_string)) } -/// `/DesignCraftData` when `exe_dir` holds [`PORTABLE_MARKER`]. A folder of that name is -/// no marker. On macOS `exe_dir` is `DesignCraft.app/Contents/MacOS`, which ships none. +/// `

/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. fn portable_dir(exe_dir: &Path) -> Option { - exe_dir.join(PORTABLE_MARKER).is_file().then(|| exe_dir.join(PORTABLE_DATA_DIR)) + let dir = app_bundle_parent(exe_dir).unwrap_or(exe_dir); + dir.join(PORTABLE_MARKER).is_file().then(|| dir.join(PORTABLE_DATA_DIR)) +} + +/// 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()))?; + let bundle = contents.parent().filter(|_| contents.file_name() == Some("Contents".as_ref()))?; + bundle.extension().filter(|ext| *ext == "app").and(bundle.parent()) } fn executable_dir() -> Option { @@ -119,6 +129,19 @@ mod tests { let _ = std::fs::remove_dir_all(&exe); } + #[test] + fn inside_a_macos_bundle_the_marker_sits_next_to_the_app() { + let folder = install("bundle", |marker| std::fs::write(marker, "").unwrap()); + let exe = folder.join("DesignCraft.app/Contents/MacOS"); + std::fs::create_dir_all(&exe).unwrap(); + assert_eq!(config_dir_with("macos", environment, Some(&exe)), Some(folder.join("DesignCraftData"))); + // a marker dropped inside the bundle is not looked at + std::fs::remove_file(folder.join(PORTABLE_MARKER)).unwrap(); + std::fs::write(exe.join(PORTABLE_MARKER), "").unwrap(); + assert_eq!(config_dir_with("macos", environment, Some(&exe)), Some("/user/Library/Application Support/DesignCraft".into())); + let _ = std::fs::remove_dir_all(&folder); + } + #[test] fn without_a_marker_file_the_platform_paths_stay() { let plain = install("plain", |_| {}); From eb4e811b522922a894e814e02bf2de3bfbbee1fd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:30:58 +0200 Subject: [PATCH 3/3] 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 d81ad553c..5f37f0e5e 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 2f90f6484..1158a54f2 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 703d97041..1450e5077 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());