diff --git a/apps/lightcraft/src/gpu_crash.rs b/apps/lightcraft/src/gpu_crash.rs new file mode 100644 index 000000000..1656d88e3 --- /dev/null +++ b/apps/lightcraft/src/gpu_crash.rs @@ -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 { + 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 { + gpu_crash_check_at(gpu_marker_path(), in_memory) +} + +pub(crate) fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option { + 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 { + 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 { + 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 { + 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() + )) +} diff --git a/apps/lightcraft/src/launch.rs b/apps/lightcraft/src/launch.rs new file mode 100644 index 000000000..a7b8904d6 --- /dev/null +++ b/apps/lightcraft/src/launch.rs @@ -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: /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=. Records go to stderr and to + /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, Vec) { + 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, Vec) { + 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 { + 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) -> Option { + 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, 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")); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 2d11a3375..7b572fa0e 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -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 { - 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 { - gpu_crash_check_at(gpu_marker_path(), in_memory) -} - -fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option { - 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 { - 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 { - 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 { - 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 { - 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> = 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 { - 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) -> 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 } -} - -/// `/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 { - 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 (`/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-` 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, Option, 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::>(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, Option, 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, - written: Vec, - 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, -} - -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, - ) -> Option { - 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 ` 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,` 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,""`. 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 = 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 = 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 (`/models`) and slideshow templates -/// (`/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) { - 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, seed_demo: bool) -> (Session, Option) { - 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: /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=. Records go to stderr and to - /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, Vec) { - 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, Vec) { - 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 { - 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) -> Option { - 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, 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")); - } -} diff --git a/apps/lightcraft/src/platform.rs b/apps/lightcraft/src/platform.rs new file mode 100644 index 000000000..3d4d87896 --- /dev/null +++ b/apps/lightcraft/src/platform.rs @@ -0,0 +1,57 @@ +/// The Windows command that opens `url` in the default browser. `explorer ` 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,` 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,""`. 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 = 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() +} diff --git a/apps/lightcraft/src/prefs.rs b/apps/lightcraft/src/prefs.rs new file mode 100644 index 000000000..0800e6f3e --- /dev/null +++ b/apps/lightcraft/src/prefs.rs @@ -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 { + 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> = 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 { + 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) -> 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 } +} + +/// `/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 { + 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 (`/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-` 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, Option, 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::>(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, Option, 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, + pub(crate) written: Vec, + 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, +} + +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, + ) -> Option { + 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(); + } + } + } +} diff --git a/apps/lightcraft/src/session.rs b/apps/lightcraft/src/session.rs new file mode 100644 index 000000000..ce3ab95b7 --- /dev/null +++ b/apps/lightcraft/src/session.rs @@ -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 = 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 (`/models`) and slideshow templates +/// (`/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) { + 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, seed_demo: bool) -> (Session, Option) { + 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()))) + } + } +}