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 <exe dir>/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 <[email protected]>
This commit is contained in:
Arekatla Nishanth ChowdaryandClaude Opus 5.5 committed 2026-10-13 11:34:33 +05:30
1 parent 68c924047d
commit edc3661680
4 files changed
+139 -18

No files matched your search

+3 -1
View File
@@ -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
+2 -1
View File
@@ -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: <settings folder>/models/sam3;
+129 -16
View File
@@ -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 `<exe dir>/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<PathBuf> {
let platform = if cfg!(target_os = "macos") {
Platform::Mac
@@ -29,13 +44,17 @@ pub fn config_dir() -> Option<PathBuf> {
} 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<OsString>) -> Option<PathBuf> {
/// `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<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
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<OsString>) -
}
}
/// 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<PathBuf> {
static DIR: OnceLock<Option<PathBuf>> = 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()
}
/// `<exe_dir>/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<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 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 `<config>/models`.
pub fn default_face_models_dir() -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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)));
}
}
+5
View File
@@ -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