//! TRACES: FR-PLAT-LIN-1 | FR-PLAT-WIN-1 | NFR-PORT-1 //! Where this application's files belong on this platform. //! //! One place for the rule, because it was in five. Each site read //! `XDG_*_HOME` and fell back to `$HOME/.local/...` on its own, which is fine //! on Linux and wrong everywhere else: Windows sets neither variable, so every //! one of them degraded to a path relative to the working directory — for a //! Start Menu launch, `C:\Windows\System32`. The callers keep their own //! override (`set_state_dir`, `set_data_dir`), which is how Android names its //! app-private directory; this answers the question those overrides do not. //! //! # The rules //! //! | | Config | Data | State | //! |---|---|---|---| //! | Unix | `$XDG_CONFIG_HOME` | `$XDG_DATA_HOME` | `$XDG_STATE_HOME` | //! | Unix default | `~/.config` | `~/.local/share` | `~/.local/state` | //! | Windows | `%APPDATA%` | `%LOCALAPPDATA%` | `%LOCALAPPDATA%`, then `state` | //! | Windows default | `%USERPROFILE%\AppData\Roaming` | `…\AppData\Local` | `…\AppData\Local` | //! | macOS default | `~/.config` | `~/.local/share` | `~/Library/Logs` | //! //! macOS follows the Unix rules except for the one directory a user is asked //! to find by hand: the log. Finder hides `~/.local`, and `~/Library/Logs` //! is where Console.app and a Mac user already look (docs/dev/macos.md). //! //! then `darkroom` under each. Config roams on Windows and the rest does not, //! which is the same split XDG makes between config and everything else, and //! the reason `settings.json` is small and the thumbnail store is not. //! //! A relative value is ignored rather than resolved: the XDG specification //! says so, and the alternative is a `darkroom/` directory wherever the app //! was launched from. With nothing usable set at all the answer is the //! temporary directory — a container or a service unit with no home — because //! writing a log into `/tmp` is a poor outcome and refusing to write one, on //! exactly the machine nobody is sitting in front of, is a worse one. use std::ffi::OsString; use std::path::{Path, PathBuf}; /// Which kind of directory. The distinction is the one every platform makes: /// what the user edits, what the application builds, and what it records. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Base { /// Settings and accounts. Small, and the user may back it up or edit it. Config, /// Catalogs, thumbnails, models. Large, rebuildable, never edited by hand. Data, /// Logs and crash records. Survives a cache sweep; not configuration. State, } /// The directory of this kind on this machine, ending in `darkroom`. /// /// Not created here: whoever writes into it creates it, so that merely asking /// leaves nothing behind. pub fn base_dir(kind: Base) -> PathBuf { resolve(kind, |name| std::env::var_os(name)) } /// Directories a package may have installed shared data into, most specific /// first, each ending in `darkroom`. /// /// Unix: `$XDG_DATA_DIRS`, defaulting to `/usr/local/share:/usr/share` — what /// the Arch package and the Flatpak install under. Windows: the directory the /// executable is in, which is where the installer puts the models /// (FR-PLAT-WIN-2). Android: none; the APK's copy is unpacked into the user /// data directory instead, because an asset inside a package is not a path /// anything can read from. pub fn system_data_dirs() -> Vec { if cfg!(target_os = "android") { return Vec::new(); } if cfg!(windows) { return std::env::current_exe() .ok() .and_then(|exe| exe.parent().map(Path::to_path_buf)) .into_iter() .collect(); } let dirs = std::env::var("XDG_DATA_DIRS") .ok() .filter(|v| !v.is_empty()) .unwrap_or_else(|| "/usr/local/share:/usr/share".into()); dirs.split(':') .filter(|d| !d.is_empty()) .map(|d| PathBuf::from(d).join("darkroom")) .collect() } /// The resolution as a function of its inputs rather than of the process /// environment, so it can be tested without `set_var` racing every other /// test in the binary — and so both platforms' rules are tested on either. fn resolve(kind: Base, env: impl Fn(&str) -> Option) -> PathBuf { let base = if cfg!(windows) { windows_base(kind, &env) } else { xdg_base(kind, &env) }; let dir = base.unwrap_or_else(std::env::temp_dir).join("darkroom"); match (kind, cfg!(windows)) { // Data and state share `%LOCALAPPDATA%`; state gets its own level so // a log and a catalog do not sit side by side. (Base::State, true) => dir.join("state"), _ => dir, } } /// Where state goes under `$HOME` when `XDG_STATE_HOME` does not say. const STATE_UNDER_HOME: &str = if cfg!(target_os = "macos") { "Library/Logs" } else { ".local/state" }; fn xdg_base(kind: Base, env: &impl Fn(&str) -> Option) -> Option { let (var, under_home) = match kind { Base::Config => ("XDG_CONFIG_HOME", ".config"), Base::Data => ("XDG_DATA_HOME", ".local/share"), Base::State => ("XDG_STATE_HOME", STATE_UNDER_HOME), }; absolute(env(var)).or_else(|| absolute(env("HOME")).map(|h| h.join(under_home))) } fn windows_base(kind: Base, env: &impl Fn(&str) -> Option) -> Option { let (var, under_profile) = match kind { Base::Config => ("APPDATA", "AppData\\Roaming"), Base::Data | Base::State => ("LOCALAPPDATA", "AppData\\Local"), }; absolute(env(var)).or_else(|| absolute(env("USERPROFILE")).map(|p| p.join(under_profile))) } /// A value only if it names an absolute path. `is_absolute` is the host's /// notion, so a test of the Windows rule on Linux uses Unix-shaped paths; /// what is tested is the variable and the suffix, which is the part that /// was wrong. fn absolute(value: Option) -> Option { value .filter(|v| Path::new(v).is_absolute()) .map(PathBuf::from) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { let map: HashMap = pairs .iter() .map(|(k, v)| (k.to_string(), OsString::from(v))) .collect(); move |name| map.get(name).cloned() } #[test] fn xdg_honours_each_variable_and_defaults_under_home() { let e = env(&[("XDG_CONFIG_HOME", "/etc/me"), ("HOME", "/home/someone")]); assert_eq!(xdg_base(Base::Config, &e), Some(PathBuf::from("/etc/me"))); assert_eq!( xdg_base(Base::Data, &e), Some(PathBuf::from("/home/someone/.local/share")) ); assert_eq!( xdg_base(Base::State, &e), Some(PathBuf::from("/home/someone").join(STATE_UNDER_HOME)) ); } #[test] fn a_relative_value_is_ignored_rather_than_resolved() { // The specification requires this, and the failure it prevents is a // `darkroom/` directory appearing in whatever the working directory // happened to be — including, on a desktop launcher, `/`. let e = env(&[("XDG_STATE_HOME", "state"), ("HOME", "/home/someone")]); assert_eq!( xdg_base(Base::State, &e), Some(PathBuf::from("/home/someone").join(STATE_UNDER_HOME)) ); assert_eq!(xdg_base(Base::State, &env(&[("HOME", "")])), None); } #[test] fn windows_splits_config_from_the_rest() { // Config roams, data and state do not. The variable is what was wrong // before this module: neither XDG_* nor HOME exists on Windows. let e = env(&[("APPDATA", "/r"), ("LOCALAPPDATA", "/l")]); assert_eq!(windows_base(Base::Config, &e), Some(PathBuf::from("/r"))); assert_eq!(windows_base(Base::Data, &e), Some(PathBuf::from("/l"))); assert_eq!(windows_base(Base::State, &e), Some(PathBuf::from("/l"))); } #[test] fn windows_falls_back_to_the_profile() { let e = env(&[("USERPROFILE", "/u")]); assert_eq!( windows_base(Base::Config, &e), Some(PathBuf::from("/u").join("AppData\\Roaming")) ); assert_eq!( windows_base(Base::Data, &e), Some(PathBuf::from("/u").join("AppData\\Local")) ); } #[test] fn nothing_set_is_still_absolute() { // A container or a service unit: the answer is somewhere writable, // never a relative path. let dir = resolve(Base::State, env(&[])); assert!(dir.is_absolute()); assert!(dir.ends_with("darkroom") || dir.ends_with("state")); } #[test] fn every_kind_ends_in_the_application_name() { let e = env(&[ ("HOME", "/home/someone"), ("APPDATA", "/r"), ("LOCALAPPDATA", "/l"), ]); for kind in [Base::Config, Base::Data, Base::State] { let dir = resolve(kind, &e); assert!( dir.components().any(|c| c.as_os_str() == "darkroom"), "{kind:?} resolved to {}", dir.display() ); } } }