From 314bfe802feababb807eeb9beeb95f9061f7453f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 14:12:31 +0200 Subject: [PATCH 1/4] Portable mode: a portable.txt beside the executable keeps the settings in VectorCraftData (#1366) --- apps/vectorcraft/src/profile.rs | 150 +++++++++++++++++++++++++++++--- docs/control-protocol.md | 2 + docs/releasing.md | 7 ++ packaging/windows/package.ps1 | 5 +- packaging/windows/portable.txt | 10 +++ packaging/windows/windows7.ps1 | 2 + 6 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 packaging/windows/portable.txt diff --git a/apps/vectorcraft/src/profile.rs b/apps/vectorcraft/src/profile.rs index c73596a01..8a5cf5ea9 100644 --- a/apps/vectorcraft/src/profile.rs +++ b/apps/vectorcraft/src/profile.rs @@ -1,17 +1,36 @@ //! Native profile paths. Isolated profiles never migrate preferences from a user's old profile. +//! +//! In order: +//! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups). +//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) beside the +//! executable puts the settings in `/VectorCraftData`, so a portable copy 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. use std::ffi::OsString; -use std::path::PathBuf; +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", "VectorCraft.portable"]; +/// The settings folder created beside the executable in portable mode. +pub const PORTABLE_DATA_DIR: &str = "VectorCraftData"; pub fn prefs_path(legacy: bool) -> Option { - prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key)) + prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir) } -fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option) -> Option { +/// `portable` is the portable settings folder, if any: only asked for when no override is set. +fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option, portable: impl FnOnce() -> Option) -> Option { if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") { // Never fall back to DrawCraft's real profile when the isolated file does not exist. return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json")); } + if !legacy && let Some(dir) = portable() { + return Some(dir.join("ui.json")); + } let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") }; let base = match os { "macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)), @@ -21,9 +40,50 @@ fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option Option { + static DIR: OnceLock> = OnceLock::new(); + DIR.get_or_init(|| { + let exe = std::env::current_exe().ok()?; + portable_dir_in(exe.parent()?).unwrap_or_else(|warning| { + log::warn!("{warning}"); + eprintln!("vectorcraft: {warning}"); + None + }) + }) + .clone() +} + +/// `/VectorCraftData` when a portable marker sits in `exe_dir`, created if missing; `None` +/// without a marker, and the warning to show when there is one but that folder can't be written. +fn portable_dir_in(exe_dir: &Path) -> Result, String> { + let Some(marker) = PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) }; + let data = exe_dir.join(PORTABLE_DATA_DIR); + match ensure_writable(&data) { + Ok(()) => Ok(Some(data)), + Err(e) => Err(format!( + "portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead", + marker.display(), + data.display() + )), + } +} + +/// 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 give up portable mode over it. + let _ = std::fs::remove_file(&probe); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; fn environment(key: &str) -> Option { match key { @@ -34,33 +94,99 @@ mod tests { } } + /// No portable marker beside the executable. + fn installed() -> Option { + None + } + #[test] fn isolated_profile_has_no_legacy_fallback_on_any_platform() { for os in ["macos", "windows", "linux"] { let env = |key: &str| { if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) } }; - assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into())); - assert_eq!(prefs_path_with(os, true, env), None); + assert_eq!(prefs_path_with(os, false, env, installed), Some("/isolated/ui.json".into())); + assert_eq!(prefs_path_with(os, true, env, installed), None); } } #[test] fn empty_override_does_not_fall_back_to_user_files() { let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) }; - assert_eq!(prefs_path_with("macos", false, env), None); - assert_eq!(prefs_path_with("macos", true, env), None); + assert_eq!(prefs_path_with("macos", false, env, installed), None); + assert_eq!(prefs_path_with("macos", true, env, installed), None); } #[test] fn unset_override_keeps_platform_paths_and_legacy_migration() { - assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into())); - assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into())); - assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into())); - assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into())); + assert_eq!(prefs_path_with("macos", false, environment, installed), Some("/user/Library/Application Support/VectorCraft/ui.json".into())); + assert_eq!(prefs_path_with("macos", true, environment, installed), Some("/user/Library/Application Support/DrawCraft/ui.json".into())); + assert_eq!(prefs_path_with("windows", false, environment, installed), Some("/roaming/VectorCraft/ui.json".into())); + assert_eq!(prefs_path_with("linux", false, environment, installed), Some("/xdg-config/vectorcraft/ui.json".into())); assert_eq!( - prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }), + prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }, installed), Some("/user/.config/drawcraft/ui.json".into()) ); } + + #[test] + fn portable_mode_keeps_the_settings_beside_the_executable() { + let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData")); + for os in ["macos", "windows", "linux"] { + assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}"); + } + // DrawCraft's old settings are still read once, as an installed copy reads them. + assert_eq!(prefs_path_with("windows", true, environment, portable), Some("/roaming/DrawCraft/ui.json".into())); + } + + #[test] + fn the_config_dir_override_wins_over_portable_mode_without_probing_it() { + let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }; + let portable = || -> Option { panic!("the portable folder is not looked for under VECTORCRAFT_CONFIG_DIR") }; + assert_eq!(prefs_path_with("windows", false, env, portable), Some("/isolated/ui.json".into())); + assert_eq!(prefs_path_with("windows", true, env, portable), None); + } + + fn temp(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("vectorcraft-profile-{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 + } + + #[test] + fn either_marker_beside_the_executable_makes_a_writable_data_folder() { + for marker in PORTABLE_MARKERS { + let exe = temp("portable"); + std::fs::write(exe.join(marker), b"").unwrap(); + let dir = portable_dir_in(&exe).unwrap().unwrap(); + assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}"); + assert!(dir.is_dir(), "the data folder is created"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed"); + let _ = std::fs::remove_dir_all(&exe); + } + } + + #[test] + fn without_a_marker_file_there_is_no_portable_folder() { + let exe = temp("installed"); + assert_eq!(portable_dir_in(&exe), Ok(None)); + // A folder named like the marker is not a marker. + std::fs::create_dir_all(exe.join("portable.txt")).unwrap(); + assert_eq!(portable_dir_in(&exe), Ok(None)); + assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker"); + let _ = std::fs::remove_dir_all(&exe); + } + + #[test] + fn an_unwritable_portable_folder_is_reported_instead_of_used() { + let exe = temp("unwritable"); + 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 warning = portable_dir_in(&exe).unwrap_err(); + assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}"); + let _ = std::fs::remove_dir_all(&exe); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2cb8f9967..d689e908a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -11,6 +11,8 @@ Set `VECTORCRAFT_CONFIG_DIR` to a dedicated directory for an isolated native tes logs, recovery and user libraries then use that root, without importing the legacy DrawCraft profile. An unset override preserves the usual platform paths and legacy migration; an explicitly empty override disables those paths. `VECTORCRAFT_NO_PREFS` continues to disable preference reads and writes. +Without the override, a `portable.txt` or `VectorCraft.portable` file beside the executable (as in the +Windows portable zip) puts that root in `VectorCraftData` next to the executable. **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else gets one error reply diff --git a/docs/releasing.md b/docs/releasing.md index bffac162e..0393e0efb 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -113,6 +113,13 @@ to the target triple, so host build scripts aren't affected. - Before packaging, the script checks each binary's PE machine type against `-Arch` (an x64 build can never ship labelled arm64), that `vectorcraft.exe` is a GUI program (no console window) and that `vectorcraft-cli.exe` is a console program. +- **Portable zip:** it ships `packaging/windows/portable.txt` beside `vectorcraft.exe`. That + marker (or a `VectorCraft.portable` file) switches on portable mode, as in PhotoCraft: `ui.json` + and the folders next to it (Swatches, Graphic Styles, Fonts, Data Recovery, logs…) go to + `VectorCraftData\` next to the exe instead of `%APPDATA%\VectorCraft`. If that folder isn't + writable the app logs a warning and uses `%APPDATA%`. The MSI has no marker. + `VECTORCRAFT_CONFIG_DIR` still wins. The logic is in `apps/vectorcraft/src/profile.rs` and works + the same on macOS and Linux. - `vectorcraft.wxs` (WiX) is a per-machine install into Program Files with a Start Menu shortcut and an App Paths entry (Win+R `vectorcraft`). Same-version upgrades are allowed, so release candidates replace each other. diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 68f423d1b..3e97b129e 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): vectorcraft--windows-.msi per-machine installer (WiX v5) - vectorcraft--windows--portable.zip vectorcraft.exe + vectorcraft-cli.exe + vectorcraft--windows--portable.zip vectorcraft.exe + vectorcraft-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 @@ -165,6 +165,9 @@ Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable Copy-Item -Recurse (Join-Path $Docs '*') $Portable +# portable.txt beside vectorcraft.exe switches on portable mode: settings, libraries, recovery +# files and logs go to VectorCraftData\ next to the exe instead of %APPDATA% (#1366; see profile.rs). +Copy-Item (Join-Path $PSScriptRoot 'portable.txt') $Portable $Zip = Join-Path $Dist "vectorcraft-$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 000000000..8885c02f2 --- /dev/null +++ b/packaging/windows/portable.txt @@ -0,0 +1,10 @@ +VectorCraft portable mode + +While this file sits next to vectorcraft.exe, VectorCraft keeps its preferences, swatch and +graphic style libraries, added fonts, crash-recovery files and logs in the VectorCraftData folder +beside it, and writes nothing to %APPDATA%. Copy the whole folder to move VectorCraft with its +settings. + +Delete this file to use the normal per-user settings folder (%APPDATA%\VectorCraft) instead. +If VectorCraftData can't be written (for example on a read-only drive), VectorCraft logs a +warning and uses %APPDATA%\VectorCraft instead. diff --git a/packaging/windows/windows7.ps1 b/packaging/windows/windows7.ps1 index 3b9d9870f..dbfa715e8 100644 --- a/packaging/windows/windows7.ps1 +++ b/packaging/windows/windows7.ps1 @@ -70,6 +70,8 @@ try { Copy-Item vendor/windows-link/license-mit (Join-Path $Stage 'LICENSE-windows-link-MIT') Copy-Item vendor/windows-link/license-apache-2.0 (Join-Path $Stage 'LICENSE-windows-link-Apache-2.0') Copy-Item docs/windows7.md (Join-Path $Stage 'WINDOWS7.md') + # portable.txt switches on portable mode, as in the regular portable zip (#1366). + Copy-Item (Join-Path $PSScriptRoot 'portable.txt') $Stage if ($env:CRAFT_FONTS_DIR) { foreach ($ofl in Get-ChildItem (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt')) { Copy-Item $ofl.FullName (Join-Path $Stage "OFL-$($ofl.Directory.Name).txt") From 9e4413964105a7f29b261c3a63a55cb3225a08a8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:19:05 +0200 Subject: [PATCH 2/4] Docs: deleting portable.txt returns settings to the user profile (#1366) --- docs/releasing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/releasing.md b/docs/releasing.md index 0393e0efb..46e68728f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -117,7 +117,8 @@ to the target triple, so host build scripts aren't affected. marker (or a `VectorCraft.portable` file) switches on portable mode, as in PhotoCraft: `ui.json` and the folders next to it (Swatches, Graphic Styles, Fonts, Data Recovery, logs…) go to `VectorCraftData\` next to the exe instead of `%APPDATA%\VectorCraft`. If that folder isn't - writable the app logs a warning and uses `%APPDATA%`. The MSI has no marker. + writable the app logs a warning and uses `%APPDATA%`. Deleting `portable.txt` returns settings to + the user profile (the `VectorCraftData` folder is left as it is). The MSI has no marker. `VECTORCRAFT_CONFIG_DIR` still wins. The logic is in `apps/vectorcraft/src/profile.rs` and works the same on macOS and Linux. - `vectorcraft.wxs` (WiX) is a per-machine install into Program Files with a Start Menu shortcut From d21040d746fa934548d4e7224c7b223e6dc55ada Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:44:01 +0200 Subject: [PATCH 3/4] Portable mode: the marker beside the .app or .AppImage, a first-start copy of the profile, nothing under NO_PREFS (#1366) The install folder is where the user sees the app: next to VectorCraft.app on macOS (nothing is written inside the signed bundle) and next to the .AppImage file on Linux ($APPIMAGE; the mounted executable's folder is read-only). The first time VectorCraftData is created, the settings in the user's profile are copied into it, so a portable zip that gains portable.txt keeps its users' settings; the originals stay, a second start copies nothing, a copy that fails halfway is logged and left as it is. VECTORCRAFT_NO_PREFS never looks for the marker or creates the folder; a portable copy skips the one-time read of DrawCraft's old settings; the unwritable-folder warning goes to the log only (eprintln! panics on a closed standard error). --- apps/vectorcraft/src/profile.rs | 268 +++++++++++++++++++++++++++----- 1 file changed, 233 insertions(+), 35 deletions(-) diff --git a/apps/vectorcraft/src/profile.rs b/apps/vectorcraft/src/profile.rs index 8a5cf5ea9..4030d0aea 100644 --- a/apps/vectorcraft/src/profile.rs +++ b/apps/vectorcraft/src/profile.rs @@ -2,72 +2,126 @@ //! //! In order: //! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups). -//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) beside the -//! executable puts the settings in `/VectorCraftData`, so a portable copy 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. +//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) in the +//! install folder puts the settings in `VectorCraftData` beside it, so a portable copy leaves +//! nothing in `%APPDATA%`. The install folder is the executable's; on macOS the one holding +//! `VectorCraft.app` (nothing is written inside a signed bundle), for an AppImage the one holding +//! the `.AppImage` file (`$APPIMAGE`; the executable runs from a read-only mount). The Windows +//! portable zip ships with `portable.txt`; the MSI doesn't. The first time `VectorCraftData` is +//! created, the settings in the user's profile are copied into it, so a portable zip that gains +//! the marker keeps its users' settings. If the folder can't be written (read-only medium, +//! Program Files), a warning is logged and the platform folder is used. A portable copy never +//! reads the old DrawCraft profile, and a run without preferences (`VECTORCRAFT_NO_PREFS`) never +//! looks for the marker. //! 3. The platform convention. 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). +/// Files in the install folder that switch on portable mode (either one; contents are ignored). pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "VectorCraft.portable"]; -/// The settings folder created beside the executable in portable mode. +/// The settings folder created in the install folder in portable mode. pub const PORTABLE_DATA_DIR: &str = "VectorCraftData"; pub fn prefs_path(legacy: bool) -> Option { prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir) } -/// `portable` is the portable settings folder, if any: only asked for when no override is set. +/// `portable` is the portable settings folder, if any: only asked for when no override is set and +/// preferences are in use. fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option, portable: impl FnOnce() -> Option) -> Option { if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") { // Never fall back to DrawCraft's real profile when the isolated file does not exist. return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json")); } - if !legacy && let Some(dir) = portable() { - return Some(dir.join("ui.json")); + // A run without preferences reads and writes nothing, so it must not create the portable folder. + if env("VECTORCRAFT_NO_PREFS").is_none() + && let Some(dir) = portable() + { + // A portable copy is self-contained: DrawCraft's old profile is not read into it. + return (!legacy).then(|| dir.join("ui.json")); } + platform_dir(os, legacy, &env).map(|p| p.join("ui.json")) +} + +/// The platform's settings folder of VectorCraft, or of DrawCraft (`legacy`). +fn platform_dir(os: &str, legacy: bool, env: &impl Fn(&str) -> Option) -> Option { let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") }; - let base = match os { + match os { "macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)), "windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)), _ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)), - }; - base.map(|p| p.join("ui.json")) + } } -/// The portable settings folder beside this executable, looked for once per process (it probes -/// the disk, and the settings path is asked for many times). +/// The portable settings folder of this copy, looked for once per process (it probes the disk, and +/// the settings path is asked for many times). fn portable_dir() -> Option { static DIR: OnceLock> = OnceLock::new(); DIR.get_or_init(|| { - let exe = std::env::current_exe().ok()?; - portable_dir_in(exe.parent()?).unwrap_or_else(|warning| { + let os = std::env::consts::OS; + let env = |key: &str| std::env::var_os(key); + let install = install_dir(os, &std::env::current_exe().ok()?, &env)?; + portable_dir_in(&install, platform_dir(os, false, &env).as_deref()).unwrap_or_else(|warning| { + // Only the log: `eprintln!` panics when standard error is a closed pipe, and the logger + // keeps early records for the log file. log::warn!("{warning}"); - eprintln!("vectorcraft: {warning}"); None }) }) .clone() } -/// `/VectorCraftData` when a portable marker sits in `exe_dir`, created if missing; `None` -/// without a marker, and the warning to show when there is one but that folder can't be written. -fn portable_dir_in(exe_dir: &Path) -> Result, String> { - let Some(marker) = PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) }; - let data = exe_dir.join(PORTABLE_DATA_DIR); - match ensure_writable(&data) { - Ok(()) => Ok(Some(data)), - Err(e) => Err(format!( +/// The folder the user sees the app in, where the portable marker is looked for: the executable's; +/// on macOS the one holding `VectorCraft.app` when the executable sits in its `Contents/MacOS` (a +/// signed bundle is never written into); on Linux the one holding the `.AppImage` file when +/// `$APPIMAGE` names it (the executable itself runs from a read-only mount). +fn install_dir(os: &str, exe: &Path, env: &impl Fn(&str) -> Option) -> Option { + let exe_dir = exe.parent()?; + let dir = match os { + "macos" => app_bundle_parent(exe_dir).unwrap_or(exe_dir).to_path_buf(), + "windows" => exe_dir.to_path_buf(), + _ => { + let appimage = env("APPIMAGE").map(PathBuf::from); + let beside = appimage.as_deref().and_then(Path::parent).filter(|p| !p.as_os_str().is_empty()); + beside.unwrap_or(exe_dir).to_path_buf() + } + }; + Some(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()) +} + +/// `/VectorCraftData` when a portable marker sits in `install_dir`, created if missing; +/// `None` without a marker, and the warning to log when there is one but that folder can't be +/// written. The first time the folder is created, the settings in `previous` (the user's profile +/// folder, when it exists) are copied into it, so a portable zip that gains the marker keeps its +/// users' settings. `previous` is never changed; a copy that fails halfway is logged and left as it +/// is, and a later start finds the folder and copies nothing. +fn portable_dir_in(install_dir: &Path, previous: Option<&Path>) -> Result, String> { + let Some(marker) = PORTABLE_MARKERS.iter().map(|m| install_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) }; + let data = install_dir.join(PORTABLE_DATA_DIR); + let first_start = !data.exists(); + if let Err(e) = ensure_writable(&data) { + return Err(format!( "portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead", marker.display(), data.display() - )), + )); } + if first_start && let Some(previous) = previous.filter(|p| p.is_dir()) { + match copy_dir(previous, &data) { + Ok(()) => log::info!("portable mode: copied the settings from {} to {}", previous.display(), data.display()), + Err(e) => log::warn!("portable mode: the settings in {} were only partly copied to {}: {e}", previous.display(), data.display()), + } + } + Ok(Some(data)) } /// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only). @@ -80,6 +134,47 @@ fn ensure_writable(dir: &Path) -> std::io::Result<()> { Ok(()) } +/// Copy the contents of `from` into `to` (which exists), going on past entries that can't be +/// copied; the error says how many were skipped and why the first one was. +fn copy_dir(from: &Path, to: &Path) -> Result<(), String> { + let mut skipped = 0usize; + let mut first: Option = None; + copy_dir_into(from, to, &mut |why| { + skipped += 1; + first.get_or_insert(why); + }); + match first { + None => Ok(()), + Some(why) => Err(format!("{skipped} skipped, the first because {why}")), + } +} + +fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) { + let entries = match std::fs::read_dir(from) { + Ok(entries) => entries, + Err(e) => return skip(format!("{}: {e}", from.display())), + }; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + skip(format!("{}: {e}", from.display())); + continue; + } + }; + let (src, dst) = (entry.path(), to.join(entry.file_name())); + // A symbolic link is copied as what it points to, or skipped when that is a folder. + let copied = match entry.file_type() { + Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)), + Ok(_) => std::fs::copy(&src, &dst).map(|_| ()), + Err(e) => Err(e), + }; + if let Err(e) = copied { + skip(format!("{}: {e}", src.display())); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -94,7 +189,7 @@ mod tests { } } - /// No portable marker beside the executable. + /// No portable marker in the install folder. fn installed() -> Option { None } @@ -130,13 +225,13 @@ mod tests { } #[test] - fn portable_mode_keeps_the_settings_beside_the_executable() { + fn portable_mode_keeps_the_settings_beside_the_executable_and_skips_the_drawcraft_profile() { let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData")); for os in ["macos", "windows", "linux"] { assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}"); + // A portable copy is self-contained: the one-time read of DrawCraft's old settings is skipped. + assert_eq!(prefs_path_with(os, true, environment, portable), None, "{os}"); } - // DrawCraft's old settings are still read once, as an installed copy reads them. - assert_eq!(prefs_path_with("windows", true, environment, portable), Some("/roaming/DrawCraft/ui.json".into())); } #[test] @@ -147,6 +242,37 @@ mod tests { assert_eq!(prefs_path_with("windows", true, env, portable), None); } + #[test] + fn a_run_without_preferences_never_looks_for_the_portable_folder() { + let env = |key: &str| if key == "VECTORCRAFT_NO_PREFS" { Some("1".into()) } else { environment(key) }; + let portable = || -> Option { panic!("the portable folder is not looked for under VECTORCRAFT_NO_PREFS") }; + assert_eq!(prefs_path_with("windows", false, env, portable), Some("/roaming/VectorCraft/ui.json".into())); + assert_eq!(prefs_path_with("windows", true, env, portable), Some("/roaming/DrawCraft/ui.json".into())); + } + + #[test] + fn on_macos_the_marker_is_looked_for_beside_the_app_bundle() { + let bundled = Path::new("/Apps/VectorCraft.app/Contents/MacOS/vectorcraft"); + assert_eq!(install_dir("macos", bundled, &environment), Some("/Apps".into())); + // A bare executable (the CLI zip) and other platforms: the executable's own folder. + assert_eq!(install_dir("macos", Path::new("/Apps/vectorcraft-cli"), &environment), Some("/Apps".into())); + assert_eq!(install_dir("windows", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into())); + assert_eq!(install_dir("linux", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into())); + } + + #[test] + fn an_appimage_looks_for_the_marker_beside_the_appimage_file() { + let mounted = Path::new("/tmp/.mount_vc/usr/bin/vectorcraft"); + let appimage = |key: &str| if key == "APPIMAGE" { Some("/home/user/Apps/VectorCraft.AppImage".into()) } else { environment(key) }; + assert_eq!(install_dir("linux", mounted, &appimage), Some("/home/user/Apps".into())); + assert_eq!(install_dir("linux", mounted, &environment), Some("/tmp/.mount_vc/usr/bin".into())); + let empty = |key: &str| if key == "APPIMAGE" { Some(OsString::new()) } else { environment(key) }; + assert_eq!(install_dir("linux", mounted, &empty), Some("/tmp/.mount_vc/usr/bin".into())); + // The variable means nothing on other platforms. + assert_eq!(install_dir("windows", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into())); + assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into())); + } + fn temp(tag: &str) -> PathBuf { static N: AtomicUsize = AtomicUsize::new(0); let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); @@ -160,7 +286,7 @@ mod tests { for marker in PORTABLE_MARKERS { let exe = temp("portable"); std::fs::write(exe.join(marker), b"").unwrap(); - let dir = portable_dir_in(&exe).unwrap().unwrap(); + let dir = portable_dir_in(&exe, None).unwrap().unwrap(); assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}"); assert!(dir.is_dir(), "the data folder is created"); assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed"); @@ -168,13 +294,30 @@ mod tests { } } + #[test] + fn a_fake_app_bundle_keeps_the_marker_and_the_data_outside_the_bundle() { + let folder = temp("bundle"); + let exe = folder.join("VectorCraft.app/Contents/MacOS/vectorcraft"); + std::fs::create_dir_all(exe.parent().unwrap()).unwrap(); + std::fs::write(folder.join("portable.txt"), b"").unwrap(); + let install = install_dir("macos", &exe, &environment).unwrap(); + assert_eq!(portable_dir_in(&install, None), Ok(Some(folder.join(PORTABLE_DATA_DIR)))); + assert!(!exe.parent().unwrap().join(PORTABLE_DATA_DIR).exists(), "nothing is written inside the bundle"); + // A marker dropped inside the bundle is not looked at. + std::fs::remove_file(folder.join("portable.txt")).unwrap(); + std::fs::write(exe.parent().unwrap().join("portable.txt"), b"").unwrap(); + let _ = std::fs::remove_dir_all(folder.join(PORTABLE_DATA_DIR)); + assert_eq!(portable_dir_in(&install, None), Ok(None)); + let _ = std::fs::remove_dir_all(&folder); + } + #[test] fn without_a_marker_file_there_is_no_portable_folder() { let exe = temp("installed"); - assert_eq!(portable_dir_in(&exe), Ok(None)); + assert_eq!(portable_dir_in(&exe, None), Ok(None)); // A folder named like the marker is not a marker. std::fs::create_dir_all(exe.join("portable.txt")).unwrap(); - assert_eq!(portable_dir_in(&exe), Ok(None)); + assert_eq!(portable_dir_in(&exe, None), Ok(None)); assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker"); let _ = std::fs::remove_dir_all(&exe); } @@ -185,8 +328,63 @@ mod tests { 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 warning = portable_dir_in(&exe).unwrap_err(); + let warning = portable_dir_in(&exe, None).unwrap_err(); assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}"); let _ = std::fs::remove_dir_all(&exe); } + + #[test] + fn the_first_start_copies_the_user_profile_into_the_data_folder_once() { + let exe = temp("upgrade"); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + let previous = temp("previous-profile"); + std::fs::write(previous.join("ui.json"), b"{\"theme\":\"dark\"}").unwrap(); + std::fs::create_dir_all(previous.join("Swatches/Mine")).unwrap(); + std::fs::write(previous.join("Swatches/Mine/warm.ase"), b"ase").unwrap(); + + let data = portable_dir_in(&exe, Some(&previous)).unwrap().unwrap(); + assert_eq!(data, exe.join(PORTABLE_DATA_DIR)); + assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the settings file is copied"); + assert_eq!(std::fs::read(data.join("Swatches/Mine/warm.ase")).unwrap(), b"ase", "nested folders are copied"); + assert_eq!(std::fs::read(previous.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the source is left as it is"); + assert!(previous.join("Swatches/Mine/warm.ase").is_file(), "the source is left as it is"); + + // A second start finds the folder and copies nothing, whatever changed on either side. + std::fs::write(data.join("ui.json"), b"{\"theme\":\"light\"}").unwrap(); + std::fs::write(previous.join("ui.json"), b"{\"theme\":\"newer\"}").unwrap(); + std::fs::write(previous.join("later.txt"), b"").unwrap(); + assert_eq!(portable_dir_in(&exe, Some(&previous)), Ok(Some(data.clone()))); + assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"light\"}", "the portable settings are not overwritten"); + assert!(!data.join("later.txt").exists(), "nothing is copied after the first start"); + + let _ = std::fs::remove_dir_all(&exe); + let _ = std::fs::remove_dir_all(&previous); + } + + #[test] + fn without_a_user_profile_the_data_folder_starts_empty() { + let exe = temp("fresh"); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + let missing = exe.join("no-such-profile"); + let data = portable_dir_in(&exe, Some(&missing)).unwrap().unwrap(); + assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&exe); + } + + #[test] + fn a_copy_that_fails_halfway_reports_what_it_skipped_and_keeps_the_rest() { + let from = temp("copy-from"); + let to = temp("copy-to"); + std::fs::write(from.join("kept.txt"), b"kept").unwrap(); + std::fs::create_dir_all(from.join("blocked")).unwrap(); + std::fs::write(from.join("blocked/inner.txt"), b"").unwrap(); + // A file where the sub-folder should go: that entry can't be copied, the others can. + std::fs::write(to.join("blocked"), b"in the way").unwrap(); + let error = copy_dir(&from, &to).unwrap_err(); + assert!(error.starts_with("1 skipped"), "{error}"); + assert!(error.contains("blocked"), "{error}"); + assert_eq!(std::fs::read(to.join("kept.txt")).unwrap(), b"kept"); + let _ = std::fs::remove_dir_all(&from); + let _ = std::fs::remove_dir_all(&to); + } } From 830dea80e0e2d3f4353b562f1494effa2d81b7cc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:44:01 +0200 Subject: [PATCH 4/4] Docs: where the portable marker goes on macOS, AppImage and Flatpak; keep a portable copy where only you can write (#1366) --- docs/control-protocol.md | 4 +++- docs/releasing.md | 18 +++++++++++++----- packaging/windows/portable.txt | 7 ++++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d689e908a..d8ce4596c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -12,7 +12,9 @@ logs, recovery and user libraries then use that root, without importing the lega An unset override preserves the usual platform paths and legacy migration; an explicitly empty override disables those paths. `VECTORCRAFT_NO_PREFS` continues to disable preference reads and writes. Without the override, a `portable.txt` or `VectorCraft.portable` file beside the executable (as in the -Windows portable zip) puts that root in `VectorCraftData` next to the executable. +Windows portable zip) puts that root in `VectorCraftData` next to the executable; on macOS the marker +and the folder sit next to `VectorCraft.app`, for an AppImage next to the `.AppImage` file, and a +Flatpak install has neither. `VECTORCRAFT_NO_PREFS` never looks for the marker or creates the folder. **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else gets one error reply diff --git a/docs/releasing.md b/docs/releasing.md index 46e68728f..6c3400908 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -116,11 +116,18 @@ to the target triple, so host build scripts aren't affected. - **Portable zip:** it ships `packaging/windows/portable.txt` beside `vectorcraft.exe`. That marker (or a `VectorCraft.portable` file) switches on portable mode, as in PhotoCraft: `ui.json` and the folders next to it (Swatches, Graphic Styles, Fonts, Data Recovery, logs…) go to - `VectorCraftData\` next to the exe instead of `%APPDATA%\VectorCraft`. If that folder isn't - writable the app logs a warning and uses `%APPDATA%`. Deleting `portable.txt` returns settings to - the user profile (the `VectorCraftData` folder is left as it is). The MSI has no marker. - `VECTORCRAFT_CONFIG_DIR` still wins. The logic is in `apps/vectorcraft/src/profile.rs` and works - the same on macOS and Linux. + `VectorCraftData\` next to the exe instead of `%APPDATA%\VectorCraft`. The first time + `VectorCraftData` is created, the settings already in `%APPDATA%\VectorCraft` are copied into it + (the originals stay), so someone updating a portable zip from before the marker keeps their + settings. If that folder isn't writable the app logs a warning and uses `%APPDATA%`. Deleting + `portable.txt` returns settings to the user profile (the `VectorCraftData` folder is left as it + is). The MSI has no marker. `VECTORCRAFT_CONFIG_DIR` still wins, `VECTORCRAFT_NO_PREFS` never + looks for the marker, and a portable copy skips the one-time read of the old DrawCraft settings. + The logic is in `apps/vectorcraft/src/profile.rs`. On the other platforms the marker goes where + the user sees the app, never inside it: on macOS next to `VectorCraft.app` (nothing is written + inside the signed bundle), for an AppImage next to the `.AppImage` file (`$APPIMAGE`; the mounted + executable's own folder is read-only). Flatpak isn't supported: `/app` is read-only and the + sandbox has no folder "beside the app", so a Flatpak install always uses its own config folder. - `vectorcraft.wxs` (WiX) is a per-machine install into Program Files with a Start Menu shortcut and an App Paths entry (Win+R `vectorcraft`). Same-version upgrades are allowed, so release candidates replace each other. @@ -236,3 +243,4 @@ warning, never a failed build. |---|---|---| | 2026-10-14 | minor | RPM glibc floor names libm's `GLIBC_2.35`: RHEL 9's glibc 2.34 provides libc's through backports | | 2026-10-12 | minor | RPM dependencies use 64-bit runtime capabilities and enforce glibc 2.35 (#1275); real distro install/start-up checks remain pending | +| 2026-10-12 | minor | Portable mode (#1366): the Windows portable zip ships `portable.txt`; where the marker goes on macOS and Linux, and what Flatpak can't do | diff --git a/packaging/windows/portable.txt b/packaging/windows/portable.txt index 8885c02f2..a8af4bc1d 100644 --- a/packaging/windows/portable.txt +++ b/packaging/windows/portable.txt @@ -3,7 +3,12 @@ VectorCraft portable mode While this file sits next to vectorcraft.exe, VectorCraft keeps its preferences, swatch and graphic style libraries, added fonts, crash-recovery files and logs in the VectorCraftData folder beside it, and writes nothing to %APPDATA%. Copy the whole folder to move VectorCraft with its -settings. +settings. The first time VectorCraftData is created, any settings already in +%APPDATA%\VectorCraft are copied into it; the originals are left where they were. + +Keep this folder somewhere only you can write to: whoever can write here decides your settings, +including the plug-ins and fonts folders VectorCraft loads from, and the Data Recovery copies of +your unsaved documents are kept here too. Delete this file to use the normal per-user settings folder (%APPDATA%\VectorCraft) instead. If VectorCraftData can't be written (for example on a read-only drive), VectorCraft logs a