Files
DarkRoom/platform/dr-plat/src/dirs.rs
T
dtourolle 872e35670c Log like a debug build on macOS, where a Mac user can find it
Nobody working on DarkRoom has a Mac, so every macOS build is in the hands
of someone who can send a log and cannot attach a debugger. Three changes
make that log worth sending:

- The desktop's default filter on macOS is `debug` for every `dr_*` crate,
  the desktop crate and `onnxruntime` (the runtime's own session log).
- The state directory — the log and crash records — is `~/Library/Logs`
  on macOS rather than the `~/.local/state` Finder hides; Console.app
  lists it. Config and data keep the Unix rules.
- A `diagnostic` cargo profile: release plus line tables, so a crash
  record's backtrace reads file:line. On macOS the tables are in the
  `.dSYM` beside the executable, which the bundle must keep.
2026-10-03 16:49:58 -04:00

231 lines
9.1 KiB
Rust

//! 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<PathBuf> {
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<OsString>) -> 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<OsString>) -> Option<PathBuf> {
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<OsString>) -> Option<PathBuf> {
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<OsString>) -> Option<PathBuf> {
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<OsString> {
let map: HashMap<String, OsString> = 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()
);
}
}
}