From edc3661680d356c323a1ab9cae814f6c399aa485 Mon Sep 17 00:00:00 2001 From: Arekatla Nishanth Chowdary Date: Tue, 13 Oct 2026 11:34:33 +0530 Subject: [PATCH] Issue #1200: portable mode, a portable.txt beside the executable keeps settings in LightCraftData As in PhotoCraft: when portable.txt (or LightCraft.portable) sits next to the executable, config_dir() is /LightCraftData, so settings, logs and models stay with a portable copy instead of %APPDATA%. LIGHTCRAFT_CONFIG_DIR still wins and is used without probing beside the executable. A portable folder that can't be written logs a warning and the platform folder is used. The check runs once per process. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- apps/lightcraft/src/main.rs | 3 +- crates/engine/src/config.rs | 145 ++++++++++++++++++++++++++++++++---- docs/whats-new.md | 5 ++ 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2aea14d87..bd17a0b5b 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,9 @@ Language**; the choice applies immediately and persists. See [docs/localization. **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder (Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS -`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A +`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. +**Portable mode:** a file named `portable.txt` (or `LightCraft.portable`) next to the executable moves the settings +folder to `LightCraftData` beside it. A launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log Folder** shows it in the file manager). Each start moves the previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index bb713a2e7..e7c33e8fd 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -666,7 +666,8 @@ OPTIONS: --version, --help ENVIRONMENT: - LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder) + LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder). + Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData) LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS, Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering. LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3; diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index 091bf5d00..b3ded1fbd 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -1,16 +1,31 @@ //! Where LightCraft keeps per-user data on this machine: the config folder (app settings, camera //! profiles, face and denoise models). //! +//! [`config_dir`] picks it, in order: +//! 1. `$LIGHTCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups). +//! 2. **Portable mode** (issue #1200, as in PhotoCraft): a marker file ([`PORTABLE_MARKERS`]) beside the +//! executable puts everything in `/LightCraftData`, so a copy on a USB stick leaves nothing in +//! `%APPDATA%`. 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: macOS `~/Library/Application Support/LightCraft`, Windows +//! `%APPDATA%\LightCraft`, Linux `$XDG_CONFIG_HOME/lightcraft` or `~/.config/lightcraft`. +//! //! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model //! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no //! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`], //! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic. use std::ffi::OsString; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use crate::Session; +/// Files beside the executable that switch on portable mode (either one; contents are ignored). +pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "LightCraft.portable"]; +/// The data folder created beside the executable in portable mode. +pub const PORTABLE_DATA_DIR: &str = "LightCraftData"; + #[derive(Clone, Copy)] enum Platform { Mac, @@ -18,9 +33,9 @@ enum Platform { Other, } -/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default. -/// The override is the complete directory; no application-name suffix is appended. An explicitly -/// empty value disables the settings directory instead of falling back to a personal profile. +/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides portable mode and the +/// platform default. The override is the complete directory; no application-name suffix is appended. An +/// explicitly empty value disables the settings directory instead of falling back to a personal profile. pub fn config_dir() -> Option { let platform = if cfg!(target_os = "macos") { Platform::Mac @@ -29,13 +44,17 @@ pub fn config_dir() -> Option { } else { Platform::Other }; - config_dir_with(platform, |name| std::env::var_os(name)) + config_dir_with(platform, |name| std::env::var_os(name), portable_dir) } -fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -> Option { +/// `portable` is asked only without an override, so an isolated run never probes beside the executable. +fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option, portable: impl FnOnce() -> Option) -> Option { if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") { return (!dir.is_empty()).then(|| PathBuf::from(dir)); } + if let Some(dir) = portable() { + return Some(dir); + } match platform { Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")), Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")), @@ -45,6 +64,50 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) - } } +/// This process's portable data folder, if a marker sits beside its executable and the folder can be +/// written. Resolved once: the check creates the folder and writes a probe file. +fn portable_dir() -> Option { + static DIR: OnceLock> = OnceLock::new(); + DIR.get_or_init(|| { + let exe = std::env::current_exe().ok()?; + match portable_dir_in(exe.parent()?) { + Ok(dir) => dir, + Err(why) => { + log::warn!("{why}"); + None + } + } + }) + .clone() +} + +/// `/LightCraftData` when `exe_dir` holds a portable marker; `Err` says why a marked folder +/// can't be used (the caller falls back to the platform folder). +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 usual 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 fail portable mode over it. + let _ = std::fs::remove_file(&probe); + Ok(()) +} + /// Where face models are kept: `$LIGHTCRAFT_FACE_MODELS` if set, else `/models`. pub fn default_face_models_dir() -> Option { std::env::var_os("LIGHTCRAFT_FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models"))) @@ -101,16 +164,62 @@ mod config_tests { use super::*; fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option { - config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value)))) + config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), || None) + } + + /// An override is used as is: portable mode is not even looked for. + fn no_portable_probe() -> Option { + panic!("an override must not probe for portable mode") + } + + fn temp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-config-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// A marker beside the executable moves the settings folder next to it, ahead of the platform folder (issue #1200). + #[test] + fn portable_marker_puts_settings_beside_the_executable() { + for marker in PORTABLE_MARKERS { + let exe = temp(&format!("portable-{marker}")); + assert_eq!(portable_dir_in(&exe), Ok(None), "no marker, no portable folder"); + assert!(!exe.join(PORTABLE_DATA_DIR).exists()); + std::fs::write(exe.join(marker), b"").unwrap(); + let data = exe.join(PORTABLE_DATA_DIR); + assert_eq!(portable_dir_in(&exe), Ok(Some(data.clone()))); + assert!(data.is_dir()); + assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0, "the write probe is removed"); + let found = portable_dir_in(&exe).unwrap(); + let actual = config_dir_with(Platform::Windows, |name| (name == "APPDATA").then(|| OsString::from("/roaming")), || found); + assert_eq!(actual, Some(data)); + let _ = std::fs::remove_dir_all(&exe); + } + } + + /// A marked folder that can't hold the data folder (here: a file is in its place) is reported, not used. + #[test] + fn portable_folder_that_cannot_be_written_is_refused() { + let exe = temp("portable-blocked"); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + std::fs::write(exe.join(PORTABLE_DATA_DIR), b"").unwrap(); + let why = portable_dir_in(&exe).unwrap_err(); + assert!(why.contains("portable.txt") && why.contains(PORTABLE_DATA_DIR), "{why}"); + let _ = std::fs::remove_dir_all(&exe); } #[test] fn config_override_wins_without_reading_user_profile() { for platform in [Platform::Mac, Platform::Windows, Platform::Other] { - let actual = config_dir_with(platform, |name| { - assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR"); - Some(OsString::from("isolated/settings")) - }); + let actual = config_dir_with( + platform, + |name| { + assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR"); + Some(OsString::from("isolated/settings")) + }, + no_portable_probe, + ); assert_eq!(actual, Some(PathBuf::from("isolated/settings"))); } } @@ -119,10 +228,14 @@ mod config_tests { fn config_empty_override_disables_profile_without_reading_defaults() { for platform in [Platform::Mac, Platform::Windows, Platform::Other] { assert_eq!( - config_dir_with(platform, |name| { - assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR"); - Some(OsString::new()) - }), + config_dir_with( + platform, + |name| { + assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR"); + Some(OsString::new()) + }, + no_portable_probe + ), None ); } @@ -145,7 +258,7 @@ mod config_tests { fn config_override_preserves_non_unicode_paths() { use std::os::unix::ffi::OsStringExt as _; let value = OsString::from_vec(b"isolated/\xff".to_vec()); - assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value))); + assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), no_portable_probe), Some(PathBuf::from(value))); } } diff --git a/docs/whats-new.md b/docs/whats-new.md index 81d9ad7fc..78d374bf8 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-13 +### Portable mode +- A file named `portable.txt` next to the LightCraft program makes it keep its settings, logs and downloaded + models in a `LightCraftData` folder beside it instead of the per-user settings folder (issue #1200), as + PhotoCraft does. If that folder can't be written, the usual folder is used and the log says why. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's