Files
DarkRoom/platform/dr-plat/src/state.rs
T
2026-08-30 14:02:40 +02:00

146 lines
6.6 KiB
Rust

//! TRACES: NFR-OPS-1
//! Where this platform lets the application keep notes about itself.
//!
//! Not the catalog, not the photographs, not the user's configuration — those
//! three already have homes (`dr_sync::account::config_dir`,
//! `dr_ui::library::data_root`, and the sidecars beside the images). This is
//! the fourth thing: the diagnostic residue an application leaves so that a
//! failure yesterday can be read about today. A log, and — when the crash
//! path lands — a crash record.
//!
//! # Why it is its own directory and not one of the other three
//!
//! XDG separates them for a reason that is not tidiness. `$XDG_CONFIG_HOME`
//! is what the user has chosen and would miss; `$XDG_DATA_HOME` is what the
//! application built and would have to rebuild; `$XDG_STATE_HOME` is
//! "state that should persist between restarts but is not important or
//! portable enough for the data directory" — which is exactly a log file. It
//! is also the directory nobody backs up, and a log is the one file here we
//! actively want a user to be able to delete without consequence.
//!
//! # Android has none of those variables, so the platform must say
//!
//! There is no `$HOME` on Android and no XDG anything (ARCH §6.9), so the
//! guesses below resolve to a path the app cannot write. `dr_sync::account`
//! learned this the expensive way — the session list went to a doomed path,
//! nothing failed loudly, and backgrounding the app lost the sign-in — and the
//! shape of the fix is copied here deliberately: the platform entry point
//! declares the directory once, before anything opens a file in it.
//!
//! **Which Android directory is a diagnostics decision, and it belongs to the
//! caller.** `AndroidApp` offers two, and they differ in precisely the way
//! that matters here:
//!
//! * `internal_data_path` — `/data/data/<pkg>/files`. Private, durable, and
//! unreachable: pulling a file out of it needs `run-as` against a debuggable
//! build, or root.
//! * `external_data_path` — `/sdcard/Android/data/<pkg>/files`. Same lifetime
//! (the system deletes it with the app, not under storage pressure — that is
//! the *cache* directory), needs no permission since API 19, and `adb pull`
//! reads it from any build.
//!
//! A log nobody can retrieve is not a diagnostic, so `darkroom-android` points
//! this at the external one. That choice has a consequence, and it is the
//! reason [`crate::diagnostics`] redacts at the sink rather than trusting call
//! sites: everything written here is readable by anyone holding the device.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Declared once by the platform entry point; a guess otherwise.
static STATE_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Declare where this platform keeps application state.
///
/// Call it before anything opens a file — installing the logger is normally
/// the very next line — because a later call is *ignored* rather than obeyed.
/// That is deliberate: two callers disagreeing about the directory would
/// otherwise split the log across two files depending on which ran first, and
/// a silently-ignored second call leaves one log rather than two halves.
///
/// Desktop needs no call. The XDG resolution below is correct there.
pub fn set_state_dir(dir: PathBuf) {
let _ = STATE_DIR.set(dir);
}
/// The directory this application's state belongs in.
///
/// It is not created here. Whoever writes into it creates it, so that merely
/// asking the question leaves nothing behind on a machine that never logs.
pub fn state_dir() -> PathBuf {
if let Some(dir) = STATE_DIR.get() {
return dir.clone();
}
xdg_state_dir(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME"))
}
/// The XDG 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.
///
/// Relative values are ignored rather than resolved against the working
/// directory: the base-directory specification says so explicitly, and the
/// alternative is a `darkroom/` directory appearing wherever the app was
/// launched from.
fn xdg_state_dir(xdg_state_home: Option<OsString>, home: Option<OsString>) -> PathBuf {
xdg_state_home
.filter(|value| Path::new(value).is_absolute())
.map(PathBuf::from)
.or_else(|| {
home.filter(|value| Path::new(value).is_absolute())
.map(|value| PathBuf::from(value).join(".local/state"))
})
// A container or a systemd unit with neither variable set. Writing a
// log into `/tmp` is a poor outcome; refusing to log at all, on the
// one kind of machine nobody is sitting in front of, is a worse one.
.unwrap_or_else(std::env::temp_dir)
.join("darkroom")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_log_goes_under_the_state_directory_the_user_named() {
// NFR-OPS-1 says "the XDG state directory", and honouring
// $XDG_STATE_HOME is the whole of what that means to a user who has
// moved theirs.
let dir = xdg_state_dir(Some("/var/lib/dr".into()), Some("/home/someone".into()));
assert_eq!(dir, PathBuf::from("/var/lib/dr/darkroom"));
}
#[test]
fn without_the_variable_it_is_the_specifications_default() {
let dir = xdg_state_dir(None, Some("/home/someone".into()));
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
}
#[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 dir = xdg_state_dir(Some("state".into()), Some("/home/someone".into()));
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
let dir = xdg_state_dir(Some("".into()), Some("".into()));
assert!(
dir.is_absolute(),
"an empty HOME must not produce a relative state directory"
);
}
#[test]
fn a_declared_directory_is_declared_once() {
// The property the entry points rely on: two callers cannot split the
// log in half. Exercised on a fresh `OnceLock` rather than the global
// one, which any other test in this binary may already have set.
let cell: OnceLock<PathBuf> = OnceLock::new();
assert!(cell.set(PathBuf::from("/first")).is_ok());
assert!(cell.set(PathBuf::from("/second")).is_err());
assert_eq!(cell.get(), Some(&PathBuf::from("/first")));
}
}