Portable mode: a portable.txt beside the executable keeps the settings in VectorCraftData (#1366)

This commit is contained in:
Marco Arnulfo
2026-10-16 15:27:55 +07:00
committed by bflatastic
parent 6fc79c33b7
commit 314bfe802f
6 changed files with 163 additions and 13 deletions
+138 -12
View File
@@ -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 `<exe dir>/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<PathBuf> {
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<OsString>) -> Option<PathBuf> {
/// `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<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
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<OsString
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).
fn portable_dir() -> Option<PathBuf> {
static DIR: OnceLock<Option<PathBuf>> = 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()
}
/// `<exe_dir>/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<Option<PathBuf>, 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<OsString> {
match key {
@@ -34,33 +94,99 @@ mod tests {
}
}
/// No portable marker beside the executable.
fn installed() -> Option<PathBuf> {
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<PathBuf> { 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);
}
}
+2
View File
@@ -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
+7
View File
@@ -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.
+4 -1
View File
@@ -5,7 +5,7 @@
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
vectorcraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
vectorcraft-<version>-windows-<arch>-portable.zip vectorcraft.exe + vectorcraft-cli.exe
vectorcraft-<version>-windows-<arch>-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
+10
View File
@@ -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.
+2
View File
@@ -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")