SettingsStore::open read XDG_CONFIG_HOME and HOME itself. Neither exists on Android, so it resolved to .config/darkroom relative to a working directory of /, and every settings edit on the tablet failed with "read-only file system" — the page reported the error and nothing said why. The doc comment claimed "the same resolution SessionStore does"; now it is, by calling the same function, which honours the directory android_main declares and takes the platform's config directory everywhere else. settings.json sits beside sessions.json on every platform, as the comment always said it did.
234 lines
8.6 KiB
Rust
234 lines
8.6 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.
|
|
///
|
|
/// **The same resolution `SessionStore` makes**, by calling the same
|
|
/// function, so `settings.json` sits beside `sessions.json` on every
|
|
/// platform. That is `dr_plat::dirs`' config directory on a desktop and
|
|
/// the directory the entry point declared on Android — the second half
|
|
/// was the bug: this used to read `XDG_CONFIG_HOME` and `HOME` itself,
|
|
/// neither exists on Android, and the result was `.config/darkroom`
|
|
/// relative to a working directory of `/`. Every settings edit on a
|
|
/// tablet failed with "read-only file system", and the page said so
|
|
/// without saying why.
|
|
pub fn open() -> Self {
|
|
Self::open_at(dr_sync::account::config_dir().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");
|
|
}
|
|
}
|