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")