From e2188c436ca4c3f2a795903dc9b3e182a665f1b3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:42:01 +0200 Subject: [PATCH] Portable mode: a portable.txt beside the exe keeps settings in EffectCraftData (#993) --- Cargo.lock | 1 + crates/host/Cargo.toml | 1 + crates/host/src/app_dirs.rs | 219 +++++++++++++++++++++++++++++++++ crates/host/src/lib.rs | 18 +-- docs/preferences.md | 3 +- docs/releasing.md | 7 ++ packaging/windows/package.ps1 | 5 +- packaging/windows/portable.txt | 12 ++ 8 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 crates/host/src/app_dirs.rs create mode 100644 packaging/windows/portable.txt diff --git a/Cargo.lock b/Cargo.lock index 595f50d6..feb9b394 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1363,6 +1363,7 @@ dependencies = [ "exr", "gif", "image", + "log", "rayon", "serde_json", ] diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 344d5433..80f61e4c 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] effectcraft-engine = { workspace = true } effectcraft-project = { workspace = true } +log = { workspace = true } effectcraft-media = { workspace = true } effectcraft-expr = { workspace = true } effectcraft-script = { workspace = true } diff --git a/crates/host/src/app_dirs.rs b/crates/host/src/app_dirs.rs new file mode 100644 index 00000000..de81ea60 --- /dev/null +++ b/crates/host/src/app_dirs.rs @@ -0,0 +1,219 @@ +//! Where EffectCraft keeps its per-user data (settings, shortcuts, crash recovery, logs, models, +//! plug-ins): the one directory [`config_dir`](crate::config_dir) returns. +//! +//! In order: +//! 1. `EFFECTCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups). +//! 2. **Portable mode** (#993): a marker file ([`PORTABLE_MARKERS`]) beside the executable puts +//! everything in `/EffectCraftData`, so a portable copy on a USB stick leaves nothing in +//! `%APPDATA%`. The Windows portable zip ships with `portable.txt`; the MSI doesn't. If that +//! folder can't be written (read-only medium, Program Files), a warning is logged and the +//! platform folder is used. +//! 3. The platform convention: macOS `~/Library/Application Support/EffectCraft`, Windows +//! `%APPDATA%\EffectCraft`, Linux `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft`. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// Files beside the executable that switch on portable mode (either one; contents are ignored). +pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "EffectCraft.portable"]; +/// The data folder created beside the executable in portable mode. +pub const PORTABLE_DATA_DIR: &str = "EffectCraftData"; + +/// How [`DataDir::dir`] was chosen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// `EFFECTCRAFT_CONFIG_DIR`. + Override, + /// `/EffectCraftData`. + Portable, + /// The platform's per-user config directory. + Platform, +} + +/// The resolved data directory. +#[derive(Clone, Debug)] +pub struct DataDir { + /// `None` only when no platform directory is known either (no `HOME`/`APPDATA`). + pub dir: Option, + pub mode: Mode, + /// Set when a portable marker was found but its data folder isn't writable. + pub warning: Option, +} + +/// The data directory for this process, resolved once (the portable check probes the disk). +pub fn current() -> &'static DataDir { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let exe = std::env::current_exe().ok(); + let d = resolve(|k| std::env::var_os(k), exe.as_deref().and_then(Path::parent)); + if let Some(w) = &d.warning { + log::warn!("{w}"); + } + d + }) +} + +/// Resolve the data directory from the environment (`env`) and the executable's folder. +pub fn resolve(env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> DataDir { + if let Some(d) = env("EFFECTCRAFT_CONFIG_DIR") { + return DataDir { dir: Some(PathBuf::from(d)), mode: Mode::Override, warning: None }; + } + let platform = platform_dir(&env); + let Some((exe_dir, marker)) = exe_dir.and_then(|d| portable_marker(d).map(|m| (d, m))) else { + return DataDir { dir: platform, mode: Mode::Platform, warning: None }; + }; + let data = exe_dir.join(PORTABLE_DATA_DIR); + match ensure_writable(&data) { + Ok(()) => DataDir { dir: Some(data), mode: Mode::Portable, warning: None }, + Err(e) => { + let fallback = platform.as_ref().map_or_else(|| "nowhere (no user folder found)".to_string(), |p| p.display().to_string()); + let warning = + format!("portable mode ({} found) but {} isn't writable ({e}); settings are kept in {fallback} instead", marker.display(), data.display()); + DataDir { dir: platform, mode: Mode::Platform, warning: Some(warning) } + } + } +} + +/// The platform convention, from `env` (so tests can fake `HOME`/`APPDATA`/`XDG_CONFIG_HOME`). +pub fn platform_dir(env: &impl Fn(&str) -> Option) -> Option { + let home = || env("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + return home().map(|h| h.join("Library/Application Support/EffectCraft")); + } + if cfg!(target_os = "windows") { + return env("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft")); + } + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft")) +} + +/// The first portable marker file in `exe_dir`, if any. +pub fn portable_marker(exe_dir: &Path) -> Option { + PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) +} + +/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only). +fn ensure_writable(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let probe = dir.join(format!(".write-test-{}", std::process::id())); + std::fs::write(&probe, b"")?; + // A leftover probe is harmless; don't fail portable mode over it. + let _ = std::fs::remove_file(&probe); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn temp(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("effectcraft-app-dirs-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn env_of(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option + use<> { + let m: HashMap = pairs.iter().map(|(k, v)| (k.to_string(), v.as_os_str().to_owned())).collect(); + move |k| m.get(k).cloned() + } + + /// A fake user folder that every platform's convention resolves under. + fn fake_home(root: &Path) -> impl Fn(&str) -> Option + use<> { + let home = root.join("home"); + let appdata = root.join("appdata"); + env_of(&[("HOME", &home), ("APPDATA", &appdata)]) + } + + #[test] + fn no_marker_uses_the_platform_dir() { + let root = temp("platform"); + let exe = root.join("bin"); + std::fs::create_dir_all(&exe).unwrap(); + let d = resolve(fake_home(&root), Some(&exe)); + assert_eq!(d.mode, Mode::Platform); + assert!(d.warning.is_none()); + let dir = d.dir.unwrap(); + if cfg!(target_os = "macos") { + assert_eq!(dir, root.join("home/Library/Application Support/EffectCraft")); + } else if cfg!(target_os = "windows") { + assert_eq!(dir, root.join("appdata").join("EffectCraft")); + } else { + assert_eq!(dir, root.join("home/.config/effectcraft")); + } + assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no portable folder without a marker"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn either_marker_keeps_everything_beside_the_exe() { + for marker in PORTABLE_MARKERS { + let root = temp("portable"); + let exe = root.join("EffectCraft"); + std::fs::create_dir_all(&exe).unwrap(); + std::fs::write(exe.join(marker), b"").unwrap(); + let d = resolve(fake_home(&root), Some(&exe)); + assert_eq!(d.mode, Mode::Portable, "{marker}"); + assert!(d.warning.is_none()); + let dir = d.dir.unwrap(); + assert_eq!(dir, exe.join("EffectCraftData")); + assert!(dir.is_dir(), "the data folder is created"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed"); + assert!(!root.join("home").exists() && !root.join("appdata").exists(), "nothing written to the user folder"); + let _ = std::fs::remove_dir_all(&root); + } + } + + #[test] + fn a_marker_folder_is_not_a_marker() { + let root = temp("dir-marker"); + let exe = root.join("bin"); + std::fs::create_dir_all(exe.join("portable.txt")).unwrap(); + assert_eq!(resolve(fake_home(&root), Some(&exe)).mode, Mode::Platform); + assert!(!exe.join(PORTABLE_DATA_DIR).exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unwritable_portable_folder_falls_back_with_a_warning() { + let root = temp("unwritable"); + let exe = root.join("bin"); + std::fs::create_dir_all(&exe).unwrap(); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + // A file where the data folder should go: it can't be created, on every platform. + std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap(); + let env = fake_home(&root); + let d = resolve(&env, Some(&exe)); + assert_eq!(d.mode, Mode::Platform); + assert_eq!(d.dir, platform_dir(&env)); + assert!(d.dir.is_some()); + let w = d.warning.unwrap(); + assert!(w.contains("portable") && w.contains("isn't writable"), "{w}"); + // No user folder at all: still no panic, just no directory. + let d = resolve(|_| None, Some(&exe)); + assert!(d.dir.is_none() && d.warning.is_some()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn override_wins_over_portable() { + let root = temp("override"); + let exe = root.join("bin"); + std::fs::create_dir_all(&exe).unwrap(); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + let custom = root.join("custom"); + let d = resolve(env_of(&[("EFFECTCRAFT_CONFIG_DIR", &custom)]), Some(&exe)); + assert_eq!((d.mode, d.dir), (Mode::Override, Some(custom))); + assert!(!exe.join(PORTABLE_DATA_DIR).exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn missing_exe_dir_or_env_never_panics() { + let d = resolve(|_| None, None); + assert_eq!((d.mode, d.dir), (Mode::Platform, None)); + } +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index cf5661a1..1e5311b0 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -12,6 +12,8 @@ use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session}; use effectcraft_project::Footage; pub use effectcraft_script as script; +pub mod app_dirs; + struct MediaImporter; impl Importer for MediaImporter { @@ -97,20 +99,10 @@ pub fn session() -> Session { /// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides): /// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows), -/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others). +/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others). A `portable.txt` +/// beside the executable puts it in `EffectCraftData` there instead ([`app_dirs`]). pub fn config_dir() -> Option { - use std::path::PathBuf; - if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") { - return Some(PathBuf::from(d)); - } - let home = || std::env::var_os("HOME").map(PathBuf::from); - if cfg!(target_os = "macos") { - return home().map(|h| h.join("Library/Application Support/EffectCraft")); - } - if cfg!(target_os = "windows") { - return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft")); - } - std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft")) + app_dirs::current().dir.clone() } #[cfg(test)] diff --git a/docs/preferences.md b/docs/preferences.md index 3b92782c..8b86528a 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -13,7 +13,8 @@ The model is `effectcraft_engine::prefs::Prefs`: serde, versioned (`version`), a keys (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`). It is stored as `prefs.json` through the session's `ConfigStore` (the desktop app uses the platform config directory: `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft` on -Windows, `$XDG_CONFIG_HOME/effectcraft` on Linux; the web app keeps it in browser storage: the +Windows, `$XDG_CONFIG_HOME/effectcraft` on Linux, or `EffectCraftData` beside the executable when a +`portable.txt` sits there, as in the Windows portable zip; the web app keeps it in browser storage: the Origin Private File System, or IndexedDB where OPFS can't write, see [web.md](web.md)). Loading migrates older layouts, keeps unknown keys (from newer versions) and falls back to defaults for values that don't parse. diff --git a/docs/releasing.md b/docs/releasing.md index 3cd1f112..bd28d8ec 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -74,6 +74,13 @@ that with stub binaries ([`test-msi.ps1`](../packaging/windows/test-msi.ps1)): a an upgrade that keeps it, a repair, uninstall, and the Program Files default. The wizard pages themselves are checked by hand. +The portable zip ships [`portable.txt`](../packaging/windows/portable.txt) beside `effectcraft.exe`. +That marker (or an `EffectCraft.portable` file) switches on portable mode: the settings folder +(preferences, shortcuts, recovery files, logs, plug-ins) becomes `EffectCraftData\` next to the +exe instead of `%APPDATA%\EffectCraft`. If that folder isn't writable the app logs a warning and +uses `%APPDATA%`. The MSI has no marker. The logic is in `crates/host/src/app_dirs.rs` and works +the same on macOS and Linux; `EFFECTCRAFT_CONFIG_DIR` still wins over it. + Every binary reports its version: `effectcraft --version`, `effectcraft-cli --version` and *Help › About EffectCraft*. diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 7f1320c8..7654fe1f 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): effectcraft--windows-.msi per-machine installer (WiX v5) - effectcraft--windows--portable.zip effectcraft.exe + effectcraft-cli.exe + effectcraft--windows--portable.zip effectcraft.exe + effectcraft-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 @@ -110,6 +110,9 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } +# portable.txt beside effectcraft.exe switches on portable mode: settings, shortcuts and recovery +# files go to EffectCraftData\ next to the exe instead of %APPDATA% (#993; see app_dirs.rs). +Copy-Item (Join-Path $PSScriptRoot 'portable.txt') $Portable $Zip = Join-Path $Dist "effectcraft-$Version-windows-$Arch-portable.zip" Remove-Item -Force $Zip -ErrorAction SilentlyContinue Compress-Archive -Path $Portable -DestinationPath $Zip diff --git a/packaging/windows/portable.txt b/packaging/windows/portable.txt new file mode 100644 index 00000000..e36056da --- /dev/null +++ b/packaging/windows/portable.txt @@ -0,0 +1,12 @@ +EffectCraft portable mode + +While this file sits next to effectcraft.exe, EffectCraft keeps its settings, keyboard shortcuts, +crash-recovery files, logs and plug-ins in the EffectCraftData folder beside it instead of +%APPDATA%\EffectCraft. Copy the whole folder to move EffectCraft with its settings. + +The disk cache, media cache and conformed audio still go to %LOCALAPPDATA%\EffectCraft unless +you choose other folders in Edit > Preferences > Disk. + +Delete this file to use the normal per-user settings folder (%APPDATA%\EffectCraft) instead. +If EffectCraftData can't be written (for example on a read-only drive), EffectCraft logs a +warning and uses %APPDATA%\EffectCraft instead.