Five sites each read XDG_*_HOME and fell back to $HOME/.local/… on their 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 models lookup walked XDG_DATA_DIRS the same way. dr_plat::dirs now holds the rule per platform: XDG on Unix, the known folders on Windows — %APPDATA% for config, which roams, and %LOCALAPPDATA% for data and state, which do not — and the executable's own directory as the system data dir, which is where the installer puts the models. The Android overrides stay where they were; only the fallback behind them moved. Both rule sets are unit-tested on either host, and the Windows one was confirmed by running the application under Wine: its log landed in AppData\Local\darkroom\state and nothing was written anywhere else.
229 lines
8.3 KiB
Rust
229 lines
8.3 KiB
Rust
//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 | NFR-OPS-3
|
|
//! Reads and writes `settings.json` beside the session config.
|
|
//!
|
|
//! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore)
|
|
//! rather than an extension of it. Two files, two lifetimes: signing out
|
|
//! forgets a session and must not discard a cache budget, and resetting
|
|
//! preferences must not revoke a credential. Merging them would couple those.
|
|
//!
|
|
//! There is no `version` field here, unlike `sessions.json`. Every field in
|
|
//! [`Settings`] is `#[serde(default)]`, so an older file is missing fields
|
|
//! rather than wrong about them, and a newer file read by an older build
|
|
//! ignores what it does not know. That covers additive change, which is the
|
|
//! only kind this record has had; a field whose *meaning* changes will need a
|
|
//! version, and that is the point to add one.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use dr_types::Settings;
|
|
|
|
/// Loads and saves device preferences.
|
|
pub struct SettingsStore {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl SettingsStore {
|
|
/// Open the store at the platform config location.
|
|
///
|
|
/// `settings.json` in the platform's config directory — `dr_plat::dirs`
|
|
/// has the rule per platform — which is the same resolution `SessionStore`
|
|
/// makes, so the two files sit together and a user backing up one takes
|
|
/// both.
|
|
pub fn open() -> Self {
|
|
Self::open_at(dr_plat::base_dir(dr_plat::Base::Config).join("settings.json"))
|
|
}
|
|
|
|
/// Open at an explicit path — for tests, and for a non-default location.
|
|
pub fn open_at(path: PathBuf) -> Self {
|
|
Self { path }
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
/// The stored settings, or the defaults.
|
|
///
|
|
/// A missing file is a first run, not a failure. An *unparseable* file is
|
|
/// also answered with defaults rather than an error, because the
|
|
/// alternative is an app that will not start until the user hand-edits
|
|
/// JSON — and the file is rewritten whole on the next save, so the damage
|
|
/// does not persist. The parse failure is logged so it is not silent.
|
|
pub fn load(&self) -> Settings {
|
|
let mut settings = match std::fs::read_to_string(&self.path) {
|
|
Ok(text) => match serde_json::from_str::<Settings>(&text) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
log::warn!(
|
|
"{} is not readable settings ({e}); using defaults",
|
|
self.path.display()
|
|
);
|
|
Settings::default()
|
|
}
|
|
},
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Settings::default(),
|
|
Err(e) => {
|
|
log::warn!("reading {}: {e}; using defaults", self.path.display());
|
|
Settings::default()
|
|
}
|
|
};
|
|
|
|
// The file is hand-editable, so nothing downstream may assume a sane
|
|
// range until this has run.
|
|
settings.sanitise();
|
|
settings
|
|
}
|
|
|
|
/// Persist settings, replacing whatever was there.
|
|
///
|
|
/// A whole-file write rather than a merge: this record is the complete set
|
|
/// of preferences and the caller is holding the copy the user just edited.
|
|
/// Merging would let a field the page does not yet expose resurrect an old
|
|
/// value the user thought they had changed.
|
|
pub fn save(&self, settings: &Settings) -> Result<(), SettingsError> {
|
|
if let Some(parent) = self.path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let json = serde_json::to_string_pretty(settings)?;
|
|
|
|
// Write and rename, so an interrupted save cannot truncate the
|
|
// existing file — the same discipline `SessionStore` uses. Settings are
|
|
// saved on every field edit, which makes the interrupted-write window
|
|
// something the user actually meets rather than a theoretical one.
|
|
let tmp = self.path.with_extension("tmp");
|
|
std::fs::write(&tmp, json)?;
|
|
std::fs::rename(&tmp, &self.path)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SettingsError {
|
|
#[error("settings io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("settings format: {0}")]
|
|
Serde(#[from] serde_json::Error),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_types::{settings::budget, ExportFormat};
|
|
|
|
fn tempdir(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"dr-settings-test-{name}-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn store(name: &str) -> (SettingsStore, PathBuf) {
|
|
let dir = tempdir(name);
|
|
(SettingsStore::open_at(dir.join("settings.json")), dir)
|
|
}
|
|
|
|
#[test]
|
|
fn a_first_run_gets_the_defaults() {
|
|
let (store, _dir) = store("first-run");
|
|
assert!(!store.path().exists());
|
|
assert_eq!(store.load(), Settings::default());
|
|
}
|
|
|
|
#[test]
|
|
fn settings_survive_a_round_trip() {
|
|
let (store, _dir) = store("round-trip");
|
|
|
|
let mut settings = Settings::default();
|
|
settings.export.format = ExportFormat::Tiff16;
|
|
settings.export.quality = 72;
|
|
settings.cache.original_budget_bytes = budget::from_gb("16");
|
|
settings.cache.keep_opened_originals = false;
|
|
|
|
store.save(&settings).unwrap();
|
|
assert_eq!(store.load(), settings);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unlimited_budget_survives_the_file() {
|
|
// `None` and `Some(0)` are different settings; a serialiser that
|
|
// flattened one into the other would turn "keep everything" into
|
|
// "keep nothing" — the worst possible confusion of the two.
|
|
let (store, _dir) = store("unlimited");
|
|
|
|
let mut settings = Settings::default();
|
|
settings.cache.original_budget_bytes = None;
|
|
store.save(&settings).unwrap();
|
|
|
|
assert_eq!(store.load().cache.original_budget_bytes, None);
|
|
}
|
|
|
|
#[test]
|
|
fn saving_creates_the_config_directory() {
|
|
// A first save on a fresh machine has no `~/.config/darkroom` yet.
|
|
let dir = tempdir("mkdir");
|
|
let store = SettingsStore::open_at(dir.join("nested").join("settings.json"));
|
|
store.save(&Settings::default()).unwrap();
|
|
assert!(store.path().exists());
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_file_yields_defaults_rather_than_failing() {
|
|
// An app that will not start until the user hand-edits JSON is worse
|
|
// than one that forgets a preference.
|
|
let (store, _dir) = store("corrupt");
|
|
std::fs::write(store.path(), "{ this is not json").unwrap();
|
|
assert_eq!(store.load(), Settings::default());
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_from_an_older_build_keeps_what_it_does_say() {
|
|
// Additive change is the case `serde(default)` covers, and the point of
|
|
// having no version field. The named value must survive.
|
|
let (store, _dir) = store("older");
|
|
std::fs::write(store.path(), r#"{"export":{"quality":55}}"#).unwrap();
|
|
|
|
let loaded = store.load();
|
|
assert_eq!(loaded.export.quality, 55);
|
|
assert_eq!(loaded.cache, dr_types::CacheSettings::default());
|
|
}
|
|
|
|
#[test]
|
|
fn load_sanitises_a_hand_edited_file() {
|
|
let (store, _dir) = store("sanitise");
|
|
std::fs::write(store.path(), r#"{"export":{"quality":250}}"#).unwrap();
|
|
assert_eq!(store.load().export.quality, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn a_save_replaces_rather_than_merging() {
|
|
let (store, _dir) = store("replace");
|
|
|
|
let mut first = Settings::default();
|
|
first.export.quality = 50;
|
|
store.save(&first).unwrap();
|
|
|
|
// The defaults again: quality must go back to 90, not stay at 50.
|
|
store.save(&Settings::default()).unwrap();
|
|
assert_eq!(store.load().export.quality, 90);
|
|
}
|
|
|
|
#[test]
|
|
fn no_temporary_file_is_left_behind() {
|
|
let (store, dir) = store("no-temp");
|
|
store.save(&Settings::default()).unwrap();
|
|
|
|
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
|
.filter(|n| n.ends_with(".tmp"))
|
|
.collect();
|
|
assert!(leftovers.is_empty(), "left {leftovers:?} behind");
|
|
}
|
|
}
|