refactor(lightcraft): split main.rs into crate-root modules

This commit is contained in:
aiongg
2026-10-26 21:39:56 +00:00
parent 59d1d6f0f8
commit 2e5597d48a
6 changed files with 853 additions and 826 deletions
+97
View File
@@ -0,0 +1,97 @@
use super::prefs::config_dir;
use super::*;
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`.
#[cfg(test)]
pub(crate) fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
window_gpu::base_options()
}
/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions
/// and `LIGHTCRAFT_NO_PREFS`.
pub(crate) fn window_files(in_memory: bool) -> window_gpu::Files {
window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none()))
}
/// Written while the GPU compute device is created, removed once that returned
/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts).
fn gpu_marker_path() -> Option<std::path::PathBuf> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return None;
}
config_dir().map(|d| d.join("gpu-init.marker"))
}
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
/// driver while creating the device), the notice to show — GPU rendering then starts off.
///
/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would
/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU
/// starts off there too, and the next ordinary launch reports and clears it.
pub(crate) fn gpu_crash_check(in_memory: bool) -> Option<String> {
gpu_crash_check_at(gpu_marker_path(), in_memory)
}
pub(crate) fn gpu_crash_check_at(marker: Option<std::path::PathBuf>, in_memory: bool) -> Option<String> {
use lightcraft_engine::gpu::backend;
let left = if in_memory {
marker.as_deref().and_then(backend::read_init_marker)
} else {
let left = marker.as_deref().and_then(backend::take_init_marker);
backend::set_init_marker(marker);
left
};
left.map(|what| gpu_crash_notice(&what))
}
pub(crate) fn gpu_crash_notice(what: &str) -> String {
format!(
"LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \
To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \
LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)."
)
}
/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next
/// launch renders on the CPU and says why.
pub(crate) fn gpu_lost_marker_path() -> Option<std::path::PathBuf> {
gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker"))
}
/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a
/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it
/// never failed.
pub(crate) fn gpu_lost_reason() -> Option<String> {
lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string))
}
/// Record that LightCraft stopped after the GPU was lost, for the next launch.
pub(crate) fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) {
let Some(m) = marker else { return };
if let Some(d) = m.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Err(e) = std::fs::write(m, format!("{reason}\n")) {
log::warn!("could not write {}: {e}", m.display());
}
}
/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a
/// `--memory` session, which leaves the disk as it found it).
pub(crate) fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option<String> {
let m = marker?;
let reason = std::fs::read_to_string(m).ok()?;
if !in_memory {
let _ = std::fs::remove_file(m);
}
Some(format!(
"LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \
is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \
an up-to-date graphics driver may help.",
reason.trim()
))
}
+138
View File
@@ -0,0 +1,138 @@
use super::prefs::config_dir;
pub(crate) const HELP: &str = "\
lightcraft — photo library + raw developer
USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported)
OPTIONS:
--library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or
a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held,
else ~/Pictures/LightCraft Library)
--no-demo don't seed a new library with the procedural demo photos
--memory throwaway in-memory session (alias --demo); nothing is saved
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
see docs/control-protocol.md)
--version, --help
ENVIRONMENT:
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;
optional: LightCraft offers to download it when first needed)
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings
from other crates); else RUST_LOG=<env_logger directives>. Records go to stderr and to
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
";
/// The launch arguments' files and folders to import (absolute), and a notice for each one that
/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374).
pub(crate) fn launch_imports(args: &[String]) -> (Vec<String>, Vec<String>) {
let cwd = std::env::current_dir().ok();
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
launch_imports_in(args, cwd.as_deref(), home.as_deref())
}
pub(crate) fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec<String>, Vec<String>) {
use lightcraft_engine::import::{LaunchPath, launch_path};
let (mut files, mut notices) = (Vec::new(), Vec::new());
for a in args {
match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) {
Some(LaunchPath::Import(p)) => files.push(p),
Some(LaunchPath::Refused { path, why }) => {
log::warn!("not importing {path} given at launch: {why}");
notices
.push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…"));
}
None => {}
}
}
(files, notices)
}
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
pub(crate) fn log_dir() -> Option<std::path::PathBuf> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return None;
}
config_dir().map(|d| d.join("logs"))
}
/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is
/// the option or variable it came from). Logged before the log file opens, so it is kept for it.
pub(crate) fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
let value = value?;
let port = value.trim().parse().ok();
if port.is_none() {
log::warn!("{what} {value:?} is not a port number; no control server");
}
port
}
/// An isolated profile keeps the emergency crash log with its other app data. With no
/// override (or an empty override), retain the historical system-temp location.
pub(crate) fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
}
/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU
/// was lost (`lost`): GPU rendering is off from the next start.
pub(crate) fn stopped_text(error: &str, lost: Option<&str>) -> String {
match lost {
Some(reason) => format!(
"{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \
Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on"
),
None => error.to_string(),
}
}
/// LightCraft stopped while its window was up: say so in the log and in a native message box
/// naming the log file (as [`startup_failed`] does for a start that failed).
pub(crate) fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) {
log::error!("LightCraft stopped: {text}");
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return; // tests and scripts: no dialog to click away
}
let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref());
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(title)
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
/// message box naming the log file, instead of exiting without a trace — the release builds have
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
pub(crate) fn startup_failed(error: &str, log_file: Option<&std::path::Path>) {
log::error!("LightCraft could not open its window: {error}");
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return; // tests and scripts: no dialog to click away
}
let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref());
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(title)
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
#[cfg(test)]
mod config_path_tests {
use super::*;
#[test]
fn config_override_keeps_panic_log_in_isolated_profile() {
let temp = std::path::PathBuf::from("temp");
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
}
}
+19 -826
View File
@@ -44,6 +44,25 @@ use lightcraft_engine::Session;
use lightcraft_ui_egui::panels::library_problem::{Launch, LibraryProblem};
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
mod gpu_crash;
mod launch;
mod platform;
mod prefs;
mod session;
use gpu_crash::{gpu_crash_check, gpu_lost_check_at, gpu_lost_marker_path, gpu_lost_reason, record_gpu_lost, window_files};
#[cfg(test)]
use gpu_crash::{gpu_crash_check_at, gpu_crash_notice, window_wgpu_options};
#[cfg(test)]
use launch::launch_imports_in;
use launch::{HELP, control_port_from, launch_imports, log_dir, panic_log_path_with, startup_failed, stopped_text, stopped_while_running};
#[cfg(test)]
use platform::{explorer_select_arg, explorer_select_command, windows_open_url_command};
use prefs::{PrefsWriter, as_remembered, load_prefs, prefs_path};
#[cfg(test)]
use prefs::{load_prefs_at, write_atomic};
use session::{open_session, services};
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
const APP_ID: &str = "ai.storyteller.lightcraft";
@@ -114,774 +133,6 @@ impl eframe::App for App {
/// a second; anything slower gives way to quitting.
const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
/// The fallback chain on top of it (DX12 → Vulkan → GL → glow) is in `window_gpu`.
#[cfg(test)]
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
window_gpu::base_options()
}
/// Where the window's renderer files live (`window_gpu::Files`): nothing for `--memory` sessions
/// and `LIGHTCRAFT_NO_PREFS`.
fn window_files(in_memory: bool) -> window_gpu::Files {
window_gpu::Files::new(config_dir().filter(|_| !in_memory && std::env::var_os("LIGHTCRAFT_NO_PREFS").is_none()))
}
/// Written while the GPU compute device is created, removed once that returned
/// (`lightcraft_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts).
fn gpu_marker_path() -> Option<std::path::PathBuf> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return None;
}
config_dir().map(|d| d.join("gpu-init.marker"))
}
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
/// driver while creating the device), the notice to show — GPU rendering then starts off.
///
/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would
/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU
/// starts off there too, and the next ordinary launch reports and clears it.
fn gpu_crash_check(in_memory: bool) -> Option<String> {
gpu_crash_check_at(gpu_marker_path(), in_memory)
}
fn gpu_crash_check_at(marker: Option<std::path::PathBuf>, in_memory: bool) -> Option<String> {
use lightcraft_engine::gpu::backend;
let left = if in_memory {
marker.as_deref().and_then(backend::read_init_marker)
} else {
let left = marker.as_deref().and_then(backend::take_init_marker);
backend::set_init_marker(marker);
left
};
left.map(|what| gpu_crash_notice(&what))
}
fn gpu_crash_notice(what: &str) -> String {
format!(
"LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \
To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \
LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)."
)
}
/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next
/// launch renders on the CPU and says why.
fn gpu_lost_marker_path() -> Option<std::path::PathBuf> {
gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker"))
}
/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a
/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it
/// never failed.
fn gpu_lost_reason() -> Option<String> {
lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string))
}
/// Record that LightCraft stopped after the GPU was lost, for the next launch.
fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) {
let Some(m) = marker else { return };
if let Some(d) = m.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Err(e) = std::fs::write(m, format!("{reason}\n")) {
log::warn!("could not write {}: {e}", m.display());
}
}
/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a
/// `--memory` session, which leaves the disk as it found it).
fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option<String> {
let m = marker?;
let reason = std::fs::read_to_string(m).ok()?;
if !in_memory {
let _ = std::fs::remove_file(m);
}
Some(format!(
"LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \
is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \
an up-to-date graphics driver may help.",
reason.trim()
))
}
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
fn display_profile_dir() -> Option<std::path::PathBuf> {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
let user = |rel: &str| home.as_ref().map(|h| h.join(rel));
let candidates: Vec<Option<std::path::PathBuf>> = if cfg!(target_os = "macos") {
vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())]
} else if cfg!(windows) {
let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
vec![Some(root.join("System32").join("spool").join("drivers").join("color"))]
} else {
let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share"));
vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())]
};
candidates.into_iter().flatten().find(|d| d.is_dir())
}
fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::config::config_dir()
}
/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size
/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it
/// opens is the system's to say.
fn as_remembered(builder: egui::ViewportBuilder, window: Option<lightcraft_ui_egui::window_place::WindowPlace>) -> egui::ViewportBuilder {
let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size));
// (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen
// to a size of its own choosing)
if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder }
}
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
fn prefs_path() -> Option<std::path::PathBuf> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return None;
}
config_dir().map(|d| d.join("ui.json"))
}
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
/// when the file exists but can't be used (issue #103): a damaged file is kept as
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
/// one that can't be read at all is not written this session (`keep_file`).
///
/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing
/// (issue #164), so a damaged file is left as it is there, not set aside.
fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
let Some(path) = prefs_path() else { return (None, None, false) };
load_prefs_at(&path, !in_memory)
}
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An
/// arrangement from an older docking version starts again from the default without a warning.
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
lightcraft_ui_egui::docking::drop_outdated(fields);
let recovered =
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(),
Ok(None) => false,
Err(_) => true,
});
if recovered {
fields.remove("docking");
}
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
Ok((ui, recovered))
}
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
Err(e) => {
let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display());
return (None, Some(msg), true);
}
};
match decode_saved_ui(&bytes) {
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
Ok((ui, true)) => {
let msg = format!(
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
path.display()
);
(Some(ui.sanitized()), Some(msg), true)
}
Err(e) if !set_aside_damaged => {
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
(None, Some(msg), true)
}
Err(e) => {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
let kept = std::fs::rename(path, &keep);
let what = match &kept {
Ok(()) => format!("it was kept as {}", keep.display()),
Err(r) => format!("it couldn't be set aside ({r})"),
};
let msg = format!(
"The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.",
path.display()
);
(None, Some(msg), kept.is_err())
}
}
}
/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full
/// disk leaves the old file or the new one, never a truncated one).
fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let tmp = path.with_extension("json.tmp");
let written = (|| {
let mut f = std::fs::File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()
})();
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, path)?;
if let Some(dir) = path.parent()
&& let Ok(d) = std::fs::File::open(dir)
{
let _ = d.sync_all();
}
Ok(())
}
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
/// changes, every few seconds when anything else changed, and at exit.
///
/// Nothing is written while the session is one that promises to save nothing: `--memory`
/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the
/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the
/// library it opened is saved then).
#[derive(Default)]
struct PrefsWriter {
/// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder).
path: Option<std::path::PathBuf>,
written: Vec<u8>,
library: String,
checked: f64,
/// The last write failed (reported once until a write works).
failing: bool,
/// `ui.json` couldn't be read at launch: never overwrite it this session.
keep_file: bool,
/// Follows the window, to remember the size it rests at (`UiState::window`).
window: lightcraft_ui_egui::window_place::WindowWatch,
/// When the window was first looked at: its watch goes by the clock from then on, not by
/// the interface's time, which stands still while the window is hidden.
window_since: Option<std::time::Instant>,
}
impl PrefsWriter {
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing))
}
/// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
/// opened) saves nothing.
fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> {
if self.keep_file || temporary {
return Ok(());
}
let Some(path) = self.path.clone() else { return Ok(()) };
if let Some(workspace) = &ui.docking {
lightcraft_ui_egui::docking::validate(workspace)?;
}
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
}
path.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| write_atomic(&path, &bytes))
.map_err(|e| format!("saving the app settings failed: {e}"))?;
self.written = bytes;
self.library = ui.settings.library_path.clone();
Ok(())
}
/// What to remember of the window as the app quits ([`WindowWatch::at_exit`]).
///
/// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit
fn window_at_exit(
&mut self,
remembered: Option<lightcraft_ui_egui::window_place::WindowPlace>,
) -> Option<lightcraft_ui_egui::window_place::WindowPlace> {
match self.window_since {
Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()),
None => remembered,
}
}
/// Per-frame host tick: records the window's placement while visible, asks for a repaint when
/// a remembered size is due, and saves settings/library at most every three seconds or
/// immediately after the library path changes; a failed save is reported once and retried.
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
// (asked for before the input is read: the context is not asked from inside its own lock)
let zoom = ctx.zoom_factor();
// The monotonic app clock: `i.time` stands still while the window is hidden.
let now = app.tick_now();
let (hidden, seen) = ctx.input(|i| {
let hidden = i.viewport().visible() == Some(false);
(hidden, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom))
});
// how the window is, to open like that next time (written with the settings below). What
// the settings hold is checked first: a script may have set it (`ui.set`). A hidden
// `run_logic` pass has no fresh content rect: keep the last known placement.
let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64();
if !hidden {
app.ui.window = self.window.note(remembered, &seen, at);
}
if let Some(wait) = self.window.waits(at) {
// (a size is only remembered once the window has rested at it: look again then, though
// nothing else asks for a frame)
ctx.request_repaint_after(wait);
}
let moved = app.ui.settings.library_path != self.library;
if !moved && now - self.checked < 3.0 {
return;
}
self.checked = now;
match self.save(app) {
Ok(()) => self.failing = false,
Err(e) => {
if !self.failing {
log::warn!("{e}");
app.notices.push(format!("{e}. LightCraft keeps trying."));
}
self.failing = true;
// don't retry every frame
self.library = app.ui.settings.library_path.clone();
}
}
}
}
/// The Windows command that opens `url` in the default browser. `explorer <url>` opened File
/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link
/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so
/// `?`, `&` and `#` need no escaping.
fn windows_open_url_command(url: &str) -> std::process::Command {
let mut c = std::process::Command::new("rundll32");
c.args(["url.dll,FileProtocolHandler", url]);
c
}
/// Explorer's `/select,<path>` switch (issue #272). Explorer parses its own command line and only
/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`),
/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that
/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain
/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line
/// always holds exactly this one argument.
fn explorer_select_arg(path: &str) -> String {
let path = path.replace('/', "\\").replace('"', "");
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(unc) => format!(r"\\{unc}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(),
};
format!("/select,\"{path}\"")
}
/// `explorer /select,"<path>"`. Rust's own argument quoting wraps the whole switch in quotes
/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't
/// recognise, so on Windows the switch goes on the command line exactly as built.
fn explorer_select_command(path: &str) -> std::process::Command {
let mut c = std::process::Command::new("explorer");
let arg = explorer_select_arg(path);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.raw_arg(arg);
}
#[cfg(not(windows))]
{
c.arg(arg);
}
c
}
/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is
/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray
/// top-level ("Failed to associate portal window with parent window" in the portal's log): a
/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so
/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself;
/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including
/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog
/// becomes a sheet and on Windows a modal, a behaviour change this does not make.
static DIALOG_PARENT: std::sync::OnceLock<rfd::FileDialog> = std::sync::OnceLock::new();
/// A file dialog owned by the app's window, or a plain one before the window exists.
fn file_dialog() -> rfd::FileDialog {
DIALOG_PARENT.get().cloned().unwrap_or_default()
}
/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints
/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder.
fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services {
let ctx = cc.egui_ctx.clone();
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc));
Services {
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
// stopped the window answering the compositor, which then reported the app as hung.
picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| {
use lightcraft_ui_egui::pick::PickKind;
let (tx, rx) = std::sync::mpsc::channel();
let repaint = ctx.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
let mut d = file_dialog();
if let Some(t) = &req.title {
d = d.set_title(t);
}
if let Some((name, exts)) = &req.filter {
d = d.add_filter_nocase(name.clone(), exts);
}
if let Some(n) = &req.file_name {
d = d.set_file_name(n);
}
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
let paths: Vec<String> = match req.kind {
PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(),
PickKind::File => d.pick_file().map(s).into_iter().collect(),
PickKind::Folder => d.pick_folder().map(s).into_iter().collect(),
PickKind::Save => d.save_file().map(s).into_iter().collect(),
};
// the app may have quit meanwhile: nobody to tell
let _ = tx.send(paths);
repaint.request_repaint();
})
.map_err(|e| format!("could not start the file dialog: {e}"))?;
Ok(rx)
})),
log_file: log_file.map(|p| p.display().to_string()),
pick_lightroom_catalog: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_folder: Some(Box::new(|| {
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
#[cfg(target_os = "linux")]
open_model_folder: Some(std::sync::Arc::new(|path: &str| {
std::process::Command::new("xdg-open")
.arg(path)
.status()
.map_err(|e| e.to_string())
.and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) })
})),
open_with: Some(Box::new(editors::open_with)),
open_all_with: Some(Box::new(editors::open_all_with)),
check_editor: Some(Box::new(editors::check)),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
return Err("only https links are opened".into());
}
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).status()
} else if cfg!(target_os = "windows") {
windows_open_url_command(url).status()
} else {
std::process::Command::new("xdg-open").arg(url).status()
};
status.map(|_| ()).map_err(|e| e.to_string())
})),
reveal: Some(Box::new(|path: &str| {
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").args(["-R", path]).status()
} else if cfg!(target_os = "windows") {
explorer_select_command(path).status()
} else {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
std::process::Command::new("xdg-open").arg(dir).status()
};
status
.map_err(|e| e.to_string())
.and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) })
})),
pick_files: Some(Box::new(|| {
file_dialog()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_denoise_model: Some(Box::new(|| {
file_dialog()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().into_owned()])
.unwrap_or_default()
})),
pick_preset_files: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
.add_filter_nocase(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
)
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_model_file: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
pick_tracklog: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
#[cfg(target_os = "macos")]
screen_profile: Some(Box::new(screen_profile::icc)),
// Linux: the profile the desktop assigns in colord, read off the UI thread
#[cfg(target_os = "linux")]
screen_profile: Some(colord::service()),
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
screen_profile: None,
pick_display_profile: Some(Box::new(|| {
let mut d = file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
if let Some(dir) = display_profile_dir() {
d = d.set_directory(dir);
}
d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default()
})),
save_preset_file: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_keyword_list: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
save_keyword_list: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_curve_preset_file: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_slideshow_templates: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_slideshow_template: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_print_templates: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_print_template: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Print Template"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
write_new: Some(Box::new(lightcraft_engine::export::write_file_new)),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
// the library is a folder on disk: backed up with the user's other files
backup_library: None,
restore_library: None,
system_theme: appearance::service(),
}
}
/// The library session with the folders face models (`<config>/models`) and slideshow templates
/// (`<config>/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet:
/// the window lists the recent ones.
fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = match launch {
Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None),
Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo),
};
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME));
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
s.apple_photos_background = true;
(s, problem)
}
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
///
/// If the library can't be opened the session is empty and in memory — never seeded with the
/// demo photos, never written anywhere — and the problem is returned: the window then says so
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
if in_memory {
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None);
}
let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock();
let Some(dir) = dir else {
log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
};
let t0 = std::time::Instant::now();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
match s.open_library(&dir, seed_demo) {
Ok(r) => {
let (replayed, torn) = (r.replayed, r.torn_bytes);
log::info!(
"library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
dir.display(),
s.catalog.len(),
if torn > 0 { ", torn tail repaired" } else { "" },
t0.elapsed().as_secs_f64() * 1000.0
);
(s, None)
}
Err(e) => {
log::error!("can't open library {}: {e}", dir.display());
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
}
}
}
const HELP: &str = "\
lightcraft — photo library + raw developer
USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported)
OPTIONS:
--library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or
a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held,
else ~/Pictures/LightCraft Library)
--no-demo don't seed a new library with the procedural demo photos
--memory throwaway in-memory session (alias --demo); nothing is saved
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
see docs/control-protocol.md)
--version, --help
ENVIRONMENT:
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;
optional: LightCraft offers to download it when first needed)
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings
from other crates); else RUST_LOG=<env_logger directives>. Records go to stderr and to
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
";
/// The launch arguments' files and folders to import (absolute), and a notice for each one that
/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374).
fn launch_imports(args: &[String]) -> (Vec<String>, Vec<String>) {
let cwd = std::env::current_dir().ok();
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
launch_imports_in(args, cwd.as_deref(), home.as_deref())
}
fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec<String>, Vec<String>) {
use lightcraft_engine::import::{LaunchPath, launch_path};
let (mut files, mut notices) = (Vec::new(), Vec::new());
for a in args {
match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) {
Some(LaunchPath::Import(p)) => files.push(p),
Some(LaunchPath::Refused { path, why }) => {
log::warn!("not importing {path} given at launch: {why}");
notices
.push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…"));
}
None => {}
}
}
(files, notices)
}
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
fn log_dir() -> Option<std::path::PathBuf> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return None;
}
config_dir().map(|d| d.join("logs"))
}
/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is
/// the option or variable it came from). Logged before the log file opens, so it is kept for it.
fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
let value = value?;
let port = value.trim().parse().ok();
if port.is_none() {
log::warn!("{what} {value:?} is not a port number; no control server");
}
port
}
/// An isolated profile keeps the emergency crash log with its other app data. With no
/// override (or an empty override), retain the historical system-temp location.
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
@@ -1119,64 +370,6 @@ fn main() -> eframe::Result {
started
}
/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU
/// was lost (`lost`): GPU rendering is off from the next start.
fn stopped_text(error: &str, lost: Option<&str>) -> String {
match lost {
Some(reason) => format!(
"{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \
Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on"
),
None => error.to_string(),
}
}
/// LightCraft stopped while its window was up: say so in the log and in a native message box
/// naming the log file (as [`startup_failed`] does for a start that failed).
fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) {
log::error!("LightCraft stopped: {text}");
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return; // tests and scripts: no dialog to click away
}
let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref());
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(title)
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
/// message box naming the log file, instead of exiting without a trace — the release builds have
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
fn startup_failed(error: &str, log_file: Option<&std::path::Path>) {
log::error!("LightCraft could not open its window: {error}");
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return; // tests and scripts: no dialog to click away
}
let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref());
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(title)
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests;
#[cfg(test)]
mod config_path_tests {
use super::*;
#[test]
fn config_override_keeps_panic_log_in_isolated_profile() {
let temp = std::path::PathBuf::from("temp");
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
}
}
+57
View File
@@ -0,0 +1,57 @@
/// The Windows command that opens `url` in the default browser. `explorer <url>` opened File
/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link
/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so
/// `?`, `&` and `#` need no escaping.
pub(crate) fn windows_open_url_command(url: &str) -> std::process::Command {
let mut c = std::process::Command::new("rundll32");
c.args(["url.dll,FileProtocolHandler", url]);
c
}
/// Explorer's `/select,<path>` switch (issue #272). Explorer parses its own command line and only
/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`),
/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that
/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain
/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line
/// always holds exactly this one argument.
pub(crate) fn explorer_select_arg(path: &str) -> String {
let path = path.replace('/', "\\").replace('"', "");
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(unc) => format!(r"\\{unc}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(),
};
format!("/select,\"{path}\"")
}
/// `explorer /select,"<path>"`. Rust's own argument quoting wraps the whole switch in quotes
/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't
/// recognise, so on Windows the switch goes on the command line exactly as built.
pub(crate) fn explorer_select_command(path: &str) -> std::process::Command {
let mut c = std::process::Command::new("explorer");
let arg = explorer_select_arg(path);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.raw_arg(arg);
}
#[cfg(not(windows))]
{
c.arg(arg);
}
c
}
/// The file dialogs' parent, set once the window exists (`services`). A dialog with a parent is
/// a child of the app's window; without one, the xdg-desktop-portal picker on Wayland is a stray
/// top-level ("Failed to associate portal window with parent window" in the portal's log): a
/// tiling compositor puts it beside the app, and a fullscreen window hides it entirely, so
/// Import Photos… looks like a hang. rfd exports the Wayland surface through xdg_foreign itself;
/// `FileDialog` is `Clone` and `Send`, so one parented template serves every picker, including
/// the one on its own thread (#191). Only set on Linux and FreeBSD: on macOS a parented dialog
/// becomes a sheet and on Windows a modal, a behaviour change this does not make.
pub(crate) static DIALOG_PARENT: std::sync::OnceLock<rfd::FileDialog> = std::sync::OnceLock::new();
/// A file dialog owned by the app's window, or a plain one before the window exists.
pub(crate) fn file_dialog() -> rfd::FileDialog {
DIALOG_PARENT.get().cloned().unwrap_or_default()
}
+245
View File
@@ -0,0 +1,245 @@
use super::*;
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
pub(crate) fn display_profile_dir() -> Option<std::path::PathBuf> {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
let user = |rel: &str| home.as_ref().map(|h| h.join(rel));
let candidates: Vec<Option<std::path::PathBuf>> = if cfg!(target_os = "macos") {
vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())]
} else if cfg!(windows) {
let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
vec![Some(root.join("System32").join("spool").join("drivers").join("color"))]
} else {
let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share"));
vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())]
};
candidates.into_iter().flatten().find(|d| d.is_dir())
}
pub(crate) fn config_dir() -> Option<std::path::PathBuf> {
lightcraft_engine::config::config_dir()
}
/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size
/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it
/// opens is the system's to say.
pub(crate) fn as_remembered(builder: egui::ViewportBuilder, window: Option<lightcraft_ui_egui::window_place::WindowPlace>) -> egui::ViewportBuilder {
let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size));
// (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen
// to a size of its own choosing)
if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder }
}
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
pub(crate) fn prefs_path() -> Option<std::path::PathBuf> {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return None;
}
config_dir().map(|d| d.join("ui.json"))
}
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
/// when the file exists but can't be used (issue #103): a damaged file is kept as
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
/// one that can't be read at all is not written this session (`keep_file`).
///
/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing
/// (issue #164), so a damaged file is left as it is there, not set aside.
pub(crate) fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
let Some(path) = prefs_path() else { return (None, None, false) };
load_prefs_at(&path, !in_memory)
}
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An
/// arrangement from an older docking version starts again from the default without a warning.
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
lightcraft_ui_egui::docking::drop_outdated(fields);
let recovered =
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(),
Ok(None) => false,
Err(_) => true,
});
if recovered {
fields.remove("docking");
}
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
Ok((ui, recovered))
}
pub(crate) fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
Err(e) => {
let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display());
return (None, Some(msg), true);
}
};
match decode_saved_ui(&bytes) {
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
Ok((ui, true)) => {
let msg = format!(
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
path.display()
);
(Some(ui.sanitized()), Some(msg), true)
}
Err(e) if !set_aside_damaged => {
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
(None, Some(msg), true)
}
Err(e) => {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
let kept = std::fs::rename(path, &keep);
let what = match &kept {
Ok(()) => format!("it was kept as {}", keep.display()),
Err(r) => format!("it couldn't be set aside ({r})"),
};
let msg = format!(
"The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.",
path.display()
);
(None, Some(msg), kept.is_err())
}
}
}
/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full
/// disk leaves the old file or the new one, never a truncated one).
pub(crate) fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let tmp = path.with_extension("json.tmp");
let written = (|| {
let mut f = std::fs::File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()
})();
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, path)?;
if let Some(dir) = path.parent()
&& let Ok(d) = std::fs::File::open(dir)
{
let _ = d.sync_all();
}
Ok(())
}
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
/// changes, every few seconds when anything else changed, and at exit.
///
/// Nothing is written while the session is one that promises to save nothing: `--memory`
/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the
/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the
/// library it opened is saved then).
#[derive(Default)]
pub(crate) struct PrefsWriter {
/// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder).
pub(crate) path: Option<std::path::PathBuf>,
pub(crate) written: Vec<u8>,
pub(crate) library: String,
pub(crate) checked: f64,
/// The last write failed (reported once until a write works).
pub(crate) failing: bool,
/// `ui.json` couldn't be read at launch: never overwrite it this session.
pub(crate) keep_file: bool,
/// Follows the window, to remember the size it rests at (`UiState::window`).
pub(crate) window: lightcraft_ui_egui::window_place::WindowWatch,
/// When the window was first looked at: its watch goes by the clock from then on, not by
/// the interface's time, which stands still while the window is hidden.
pub(crate) window_since: Option<std::time::Instant>,
}
impl PrefsWriter {
pub(crate) fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing))
}
/// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
/// opened) saves nothing.
pub(crate) fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> {
if self.keep_file || temporary {
return Ok(());
}
let Some(path) = self.path.clone() else { return Ok(()) };
if let Some(workspace) = &ui.docking {
lightcraft_ui_egui::docking::validate(workspace)?;
}
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
}
path.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| write_atomic(&path, &bytes))
.map_err(|e| format!("saving the app settings failed: {e}"))?;
self.written = bytes;
self.library = ui.settings.library_path.clone();
Ok(())
}
/// What to remember of the window as the app quits ([`WindowWatch::at_exit`]).
///
/// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit
pub(crate) fn window_at_exit(
&mut self,
remembered: Option<lightcraft_ui_egui::window_place::WindowPlace>,
) -> Option<lightcraft_ui_egui::window_place::WindowPlace> {
match self.window_since {
Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()),
None => remembered,
}
}
/// Per-frame host tick: records the window's placement while visible, asks for a repaint when
/// a remembered size is due, and saves settings/library at most every three seconds or
/// immediately after the library path changes; a failed save is reported once and retried.
pub(crate) fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
// (asked for before the input is read: the context is not asked from inside its own lock)
let zoom = ctx.zoom_factor();
// The monotonic app clock: `i.time` stands still while the window is hidden.
let now = app.tick_now();
let (hidden, seen) = ctx.input(|i| {
let hidden = i.viewport().visible() == Some(false);
(hidden, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom))
});
// how the window is, to open like that next time (written with the settings below). What
// the settings hold is checked first: a script may have set it (`ui.set`). A hidden
// `run_logic` pass has no fresh content rect: keep the last known placement.
let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64();
if !hidden {
app.ui.window = self.window.note(remembered, &seen, at);
}
if let Some(wait) = self.window.waits(at) {
// (a size is only remembered once the window has rested at it: look again then, though
// nothing else asks for a frame)
ctx.request_repaint_after(wait);
}
let moved = app.ui.settings.library_path != self.library;
if !moved && now - self.checked < 3.0 {
return;
}
self.checked = now;
match self.save(app) {
Ok(()) => self.failing = false,
Err(e) => {
if !self.failing {
log::warn!("{e}");
app.notices.push(format!("{e}. LightCraft keeps trying."));
}
self.failing = true;
// don't retry every frame
self.library = app.ui.settings.library_path.clone();
}
}
}
}
+297
View File
@@ -0,0 +1,297 @@
#[cfg(any(target_os = "freebsd", target_os = "linux"))]
use super::platform::DIALOG_PARENT;
use super::platform::{explorer_select_command, file_dialog, windows_open_url_command};
use super::prefs::display_profile_dir;
use super::*;
/// The platform services; `cc` is the window the file dialogs belong to (its `egui_ctx` repaints
/// when a dialog closes), and `log_file` is for Help ▸ Open Log Folder.
pub(crate) fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>) -> Services {
let ctx = cc.egui_ctx.clone();
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc));
Services {
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
// stopped the window answering the compositor, which then reported the app as hung.
picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| {
use lightcraft_ui_egui::pick::PickKind;
let (tx, rx) = std::sync::mpsc::channel();
let repaint = ctx.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
let mut d = file_dialog();
if let Some(t) = &req.title {
d = d.set_title(t);
}
if let Some((name, exts)) = &req.filter {
d = d.add_filter_nocase(name.clone(), exts);
}
if let Some(n) = &req.file_name {
d = d.set_file_name(n);
}
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
let paths: Vec<String> = match req.kind {
PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(),
PickKind::File => d.pick_file().map(s).into_iter().collect(),
PickKind::Folder => d.pick_folder().map(s).into_iter().collect(),
PickKind::Save => d.save_file().map(s).into_iter().collect(),
};
// the app may have quit meanwhile: nobody to tell
let _ = tx.send(paths);
repaint.request_repaint();
})
.map_err(|e| format!("could not start the file dialog: {e}"))?;
Ok(rx)
})),
log_file: log_file.map(|p| p.display().to_string()),
pick_lightroom_catalog: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_folder: Some(Box::new(|| {
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
#[cfg(target_os = "linux")]
open_model_folder: Some(std::sync::Arc::new(|path: &str| {
std::process::Command::new("xdg-open")
.arg(path)
.status()
.map_err(|e| e.to_string())
.and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) })
})),
open_with: Some(Box::new(editors::open_with)),
open_all_with: Some(Box::new(editors::open_all_with)),
check_editor: Some(Box::new(editors::check)),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
return Err("only https links are opened".into());
}
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).status()
} else if cfg!(target_os = "windows") {
windows_open_url_command(url).status()
} else {
std::process::Command::new("xdg-open").arg(url).status()
};
status.map(|_| ()).map_err(|e| e.to_string())
})),
reveal: Some(Box::new(|path: &str| {
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").args(["-R", path]).status()
} else if cfg!(target_os = "windows") {
explorer_select_command(path).status()
} else {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
std::process::Command::new("xdg-open").arg(dir).status()
};
status
.map_err(|e| e.to_string())
.and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) })
})),
pick_files: Some(Box::new(|| {
file_dialog()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_denoise_model: Some(Box::new(|| {
file_dialog()
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().into_owned()])
.unwrap_or_default()
})),
pick_preset_files: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
.add_filter_nocase(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
)
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_model_file: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Add a Face Recognition Model"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Face models (ONNX)"), &["onnx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
pick_tracklog: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
// macOS doesn't colour-manage the window: previews follow the screen's profile (#787)
#[cfg(target_os = "macos")]
screen_profile: Some(Box::new(screen_profile::icc)),
// Linux: the profile the desktop assigns in colord, read off the UI thread
#[cfg(target_os = "linux")]
screen_profile: Some(colord::service()),
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
screen_profile: None,
pick_display_profile: Some(Box::new(|| {
let mut d = file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]);
if let Some(dir) = display_profile_dir() {
d = d.set_directory(dir);
}
d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default()
})),
save_preset_file: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_keyword_list: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
})),
save_keyword_list: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Keywords"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_curve_preset_files: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_curve_preset_file: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_slideshow_templates: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_slideshow_template: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_print_templates: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_print_template: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Print Template"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
write_new: Some(Box::new(lightcraft_engine::export::write_file_new)),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
// the library is a folder on disk: backed up with the user's other files
backup_library: None,
restore_library: None,
system_theme: appearance::service(),
}
}
/// The library session with the folders face models (`<config>/models`) and slideshow templates
/// (`<config>/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet:
/// the window lists the recent ones.
pub(crate) fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = match launch {
Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None),
Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo),
};
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME));
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
s.apple_photos_background = true;
(s, problem)
}
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
///
/// If the library can't be opened the session is empty and in memory — never seeded with the
/// demo photos, never written anywhere — and the problem is returned: the window then says so
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
if in_memory {
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_default_denoise_models().with_system_clock(), None);
}
let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock();
let Some(dir) = dir else {
log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
};
let t0 = std::time::Instant::now();
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
match s.open_library(&dir, seed_demo) {
Ok(r) => {
let (replayed, torn) = (r.replayed, r.torn_bytes);
log::info!(
"library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
dir.display(),
s.catalog.len(),
if torn > 0 { ", torn tail repaired" } else { "" },
t0.elapsed().as_secs_f64() * 1000.0
);
(s, None)
}
Err(e) => {
log::error!("can't open library {}: {e}", dir.display());
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
}
}
}