mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 15:14:11 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39f79f1846 |
No files matched your search
@@ -0,0 +1,234 @@
|
||||
//! Where the desktop app keeps its per-user data: preferences, brush presets, crash-recovery
|
||||
//! autosaves and the GPU startup marker all live under the one directory [`config_dir`] returns.
|
||||
//!
|
||||
//! In order:
|
||||
//! 1. `PHOTOCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (#228): a marker file ([`PORTABLE_MARKERS`]) beside the executable puts
|
||||
//! everything in `<exe dir>/PhotoCraftData`, 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), the app warns and falls back.
|
||||
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
|
||||
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
|
||||
|
||||
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", "PhotoCraft.portable"];
|
||||
/// The data folder created beside the executable in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "PhotoCraftData";
|
||||
|
||||
/// How [`DataDir::dir`] was chosen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// `PHOTOCRAFT_CONFIG_DIR`.
|
||||
Override,
|
||||
/// `<exe dir>/PhotoCraftData`.
|
||||
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<PathBuf>,
|
||||
pub mode: Mode,
|
||||
/// Set when a portable marker was found but its data folder isn't writable.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// The data directory for this process, resolved once (the portable check probes the disk).
|
||||
pub fn current() -> &'static DataDir {
|
||||
static DIR: OnceLock<DataDir> = 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}");
|
||||
eprintln!("photocraft: {w}");
|
||||
}
|
||||
log::info!("settings directory ({:?}): {:?}", d.mode, d.dir);
|
||||
d
|
||||
})
|
||||
}
|
||||
|
||||
/// The per-user settings directory (see the module docs).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
current().dir.clone()
|
||||
}
|
||||
|
||||
/// Resolve the data directory from the environment (`env`) and the executable's folder.
|
||||
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
|
||||
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
|
||||
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<OsString>) -> Option<PathBuf> {
|
||||
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home.map(|h| h.join("Library/Application Support/Photocraft"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return env("APPDATA").filter(|a| !a.is_empty()).map(|a| PathBuf::from(a).join("Photocraft"));
|
||||
}
|
||||
env("XDG_CONFIG_HOME").filter(|x| !x.is_empty()).map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
|
||||
}
|
||||
|
||||
/// The first portable marker file in `exe_dir`, if any.
|
||||
pub fn portable_marker(exe_dir: &Path) -> Option<PathBuf> {
|
||||
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!("photocraft-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<OsString> + use<> {
|
||||
let m: HashMap<String, OsString> = 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<OsString> + 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 env = fake_home(&root);
|
||||
let d = resolve(&env, Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Platform);
|
||||
assert!(d.warning.is_none());
|
||||
let dir = d.dir.unwrap();
|
||||
assert_eq!(Some(dir.clone()), platform_dir(&env));
|
||||
assert!(!dir.starts_with(&exe));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no portable folder without a marker");
|
||||
// The convention per platform.
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(dir, root.join("home/Library/Application Support/Photocraft"));
|
||||
} else if cfg!(windows) {
|
||||
assert_eq!(dir, root.join("appdata").join("Photocraft"));
|
||||
} else {
|
||||
assert_eq!(dir, root.join("home/.config/photocraft"));
|
||||
}
|
||||
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("PhotoCraft");
|
||||
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(PORTABLE_DATA_DIR));
|
||||
assert!(dir.is_dir(), "the data folder is created");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
|
||||
// Every derived path sits under the exe dir.
|
||||
for sub in ["preferences.json", "Presets", "Recovery", crate::gpu_startup::MARKER_FILE] {
|
||||
assert!(dir.join(sub).starts_with(&exe));
|
||||
}
|
||||
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);
|
||||
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));
|
||||
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(&[("PHOTOCRAFT_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));
|
||||
let empty = PathBuf::new();
|
||||
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None);
|
||||
assert_eq!(d.dir, None, "empty variables are ignored");
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod app_dirs;
|
||||
mod app_icon;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
@@ -223,6 +224,10 @@ fn main() -> eframe::Result {
|
||||
tablet::spawn_x11(&app.stylus.feed, tablet::DisplayKind::of(cc));
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
// Portable marker found but its data folder isn't writable (#228): say where settings went.
|
||||
if let Some(w) = &app_dirs::current().warning {
|
||||
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false);
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -36,21 +36,11 @@ fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])>
|
||||
v
|
||||
}
|
||||
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
|
||||
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
|
||||
/// `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`).
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
|
||||
/// portable mode, else the platform convention. Everything the app persists lives under it; see
|
||||
/// [`crate::app_dirs`].
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if let Some(d) = std::env::var_os("PHOTOCRAFT_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/Photocraft"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Photocraft"));
|
||||
}
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
|
||||
crate::app_dirs::config_dir()
|
||||
}
|
||||
|
||||
pub fn prefs_file() -> Option<PathBuf> {
|
||||
|
||||
@@ -102,7 +102,9 @@ log text file. GPU on/off and the GPU tile size apply at the next launch.
|
||||
The desktop app stores them in `preferences.json` in the platform config directory (macOS
|
||||
`~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
|
||||
`$XDG_CONFIG_HOME/photocraft`; override with `PHOTOCRAFT_CONFIG_DIR`); autosaves go to its
|
||||
`Recovery` folder. The web build keeps them in `localStorage`.
|
||||
`Recovery` folder. In portable mode (a `portable.txt` or `PhotoCraft.portable` file beside the
|
||||
executable, as in the Windows portable zip) that directory is `PhotoCraftData` next to the
|
||||
executable instead. The web build keeps them in `localStorage`.
|
||||
|
||||
User and imported (`.abr`) brush presets live in the config directory's `Presets` folder: one
|
||||
`.pcbrushes` JSON file per preset group, content-addressed tip bitmaps under `tips/`, and an
|
||||
|
||||
@@ -107,6 +107,12 @@ goes in `CARGO_TARGET_<TRIPLE>_RUSTFLAGS`, so host build scripts aren't affected
|
||||
as the cached icon filename, and an extensionless filename can render as a blank document
|
||||
icon. `packaging/windows/check-icons.ps1` checks the references and extensions in CI;
|
||||
`package.ps1` also validates the built MSI with ICE50 before signing it.
|
||||
- **Portable zip:** it ships `packaging/windows/portable.txt` beside `photocraft.exe`. That
|
||||
marker (or a `PhotoCraft.portable` file) switches on portable mode: preferences, presets,
|
||||
recovery autosaves and the GPU startup marker go to `PhotoCraftData\` next to the exe instead
|
||||
of `%APPDATA%\Photocraft`. If that folder isn't writable the app warns and uses `%APPDATA%`.
|
||||
The MSI has no marker. The logic is in `apps/photocraft/src/app_dirs.rs` and works the same
|
||||
on macOS and Linux.
|
||||
- **Signing:** `packaging/windows/sign.ps1` signs both `.exe` files and then the `.msi` with
|
||||
`signtool`, using SHA-256 and an RFC 3161 timestamp. It uses whichever material is present:
|
||||
1. a `.pfx` file (`WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD`), timestamped by
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.DESCRIPTION
|
||||
Produces, in $env:DIST (default: dist/release):
|
||||
photocraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
|
||||
photocraft-<version>-windows-<arch>-portable.zip photocraft.exe + photocraft-cli.exe
|
||||
photocraft-<version>-windows-<arch>-portable.zip photocraft.exe + photocraft-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
|
||||
@@ -96,6 +96,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 photocraft.exe switches on portable mode: settings, presets and recovery
|
||||
# files go to PhotoCraftData\ next to the exe instead of %APPDATA% (#228; see app_dirs.rs).
|
||||
Copy-Item (Join-Path $PSScriptRoot 'portable.txt') $Portable
|
||||
$Zip = Join-Path $Dist "photocraft-$Version-windows-$Arch-portable.zip"
|
||||
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $Portable -DestinationPath $Zip
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
PhotoCraft portable mode
|
||||
|
||||
While this file sits next to photocraft.exe, PhotoCraft keeps its preferences, brush presets,
|
||||
crash-recovery autosaves and startup state in the PhotoCraftData folder beside it, and writes
|
||||
nothing to %APPDATA%. Copy the whole folder to move PhotoCraft with its settings.
|
||||
|
||||
Delete this file to use the normal per-user settings folder (%APPDATA%\Photocraft) instead.
|
||||
If PhotoCraftData can't be written (for example on a read-only drive), PhotoCraft says so and
|
||||
uses %APPDATA%\Photocraft instead.
|
||||
Reference in new issue
Block a user