mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Portable mode: a portable.txt beside the executable keeps the settings in VectorCraftData (#1366)
This commit is contained in:
committed by
bflatastic
parent
6fc79c33b7
commit
314bfe802f
+138
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user