diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 39d90f07bb..a75520f3c4 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -10,8 +10,8 @@ //! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows //! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`. //! -//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the -//! roaming profile ([`recovery_dir`]). +//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced +//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]). use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -77,18 +77,35 @@ pub fn recovery_dir() -> Option { }) } -/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every -/// few minutes, so on Windows the platform convention puts them in the machine-local -/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and -/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before -/// a crashed document is recovered. The override and portable folders keep everything in one -/// place, and other platforms already keep these folders local. +/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few +/// minutes, so with the platform convention they stay out of what is synced with the settings: +/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming +/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy. +/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default +/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that +/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy +/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored. +/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put +/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the +/// system. +/// +/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed +/// document is recovered. The override and portable folders keep everything in one place. pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option { - if cfg!(windows) - && data.mode == Mode::Platform - && let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) - { - return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + if data.mode == Mode::Platform { + if cfg!(windows) { + if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) { + return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + } + } else if !cfg!(target_os = "macos") { + let state = env("XDG_STATE_HOME") + .map(PathBuf::from) + .filter(|s| s.is_absolute()) + .or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state"))); + if let Some(state) = state { + return Some(state.join("photocraft").join(RECOVERY)); + } + } } data.dir.as_ref().map(|d| d.join(RECOVERY)) } @@ -623,6 +640,34 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default + /// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows + /// and macOS don't read it, and an override or portable folder keeps them inside itself. + #[test] + fn autosaves_go_to_the_xdg_state_folder() { + let root = temp("recovery-xdg"); + let settings = root.join("home").join(".config").join("photocraft"); + let home = root.join("home"); + let state = root.join("state"); + let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None }; + let xdg = !cfg!(windows) && !cfg!(target_os = "macos"); + let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") }); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery"))); + let default = home.join(".local").join("state").join("photocraft").join("Recovery"); + assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone())); + let relative = PathBuf::from("relative/state"); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone())); + let empty = PathBuf::new(); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default)); + // No home at all: beside the settings, as before. + assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery"))); + for mode in [Mode::Override, Mode::Portable] { + let d = DataDir { dir: Some(settings.clone()), mode, warning: None }; + assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}"); + } + let _ = std::fs::remove_dir_all(&root); + } + /// An autosave bundle and its sidecar, as `format::Autosaver` writes them. fn autosave(dir: &Path, key: &str) { std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap(); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 20de0d6be8..80f0b2d3a1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -293,7 +293,7 @@ fn block_on(future: impl Future) -> T { /// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `/PhotoCraftData` in /// portable mode, else the platform convention. Everything the app persists lives under it -/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`]. +/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`]. pub fn config_dir() -> Option { crate::app_dirs::config_dir() } @@ -308,8 +308,9 @@ pub fn presets_dir() -> Option { config_dir().map(|d| d.join("Presets")) } -/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay -/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]). +/// Crash-recovery autosaves: under the settings directory, except where that is synced with the +/// settings: the Windows roaming profile and the Linux config folder (see +/// [`crate::app_dirs::resolve_recovery`]). fn recovery_dir() -> Option { crate::app_dirs::recovery_dir() } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e6d5c9591d..78f44b07d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -248,7 +248,9 @@ The desktop app stores them in `preferences.json` in the platform config directo `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft`; override with `PHOTOCRAFT_CONFIG_DIR`); autosaves go to its `Recovery` folder (on Windows to `%LOCALAPPDATA%\Photocraft\Recovery`, out of the roaming -profile; autosaves left by earlier versions move there at startup), and the window size and +profile, and on Linux to `$XDG_STATE_HOME/photocraft/Recovery`, by default +`~/.local/state/photocraft/Recovery`, out of the config folder; autosaves left by earlier +versions move there at startup), and the window size and position, the dock width and floating-panel geometry to `ui.ron` beside it (a file holding values that would crash startup is discarded). In portable mode (a `portable.txt` or `PhotoCraft.portable` file beside the executable, as in the Windows portable zip) that directory is `PhotoCraftData` next to the