Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
146 lines
6.6 KiB
Rust
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")));
|
|
}
|
|
}
|