Files
DarkRoom/ui/dr-ui/src/settings_store.rs
T
dtourolleandClaude Opus 5 fa12afed18
Build and test / Desktop (Linux) (push) Failing after 1s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Layer separation (push) Failing after 1s
Traceability / Requirement traces (push) Failing after 2s
Keep originals on this device, by pin and by use
Fills in `image_cache`, which the previous commit's "On this device" filter
read but nothing wrote. Also carries in-flight work that shared these files:
the Android TLS root store, the settings page, and a regenerated
traceability report.

# Two populations, deliberately separate

An original is kept here for one of two reasons, and conflating them produces
the exact failure the feature exists to prevent.

**Pinned** originals were asked for. Pinning a collection before a trip is a
promise, so pinned rows are never evicted and never counted against the
budget — a cap that could silently delete a pinned trip would make pinning
worthless, because it could not be relied on without checking.

**Passively cached** originals are a side effect of working: develop already
downloads the whole file, so keeping it costs no bandwidth and saves the
entire transfer next time. This population is what the budget bounds, evicted
least-recently-used, because it otherwise grows until a day of culling fills
a disk.

Sharing one budget would let a large pin starve the passive cache, or let
browsing evict a pin. They are separate.

# What was built

`dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned
— and writes the bytes; deciding to download stays with the caller, which is
what keeps a crate with no network out of the network's business. Files are
written to a temporary and renamed, so a dropped connection cannot leave a
truncated file recorded as a complete original. They are named by image id,
not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different
photographs, and a flat cache keyed on the name would serve one for the other.

`spawn_full_fetch` became read-through. A hit is a disk read; a miss stores
what it downloads and enforces the budget. A cache that cannot be opened is a
miss, not a failure to open the photograph.

Pinning writes intent — `tier_desired` — without downloading, so the button
responds immediately, and `spawn_pin_fetch` fills it in sequentially
afterwards. Sequential because these are tens of megabytes each: the lanes
that make the thumbnail sweep fast buy little against one connection's
bandwidth and cost a great deal of memory. A pin interrupted by a lost
connection resumes from where it stopped.

Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something
inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot
answer for an image whose rule was deleted. A v4 catalog migrates in place;
existing rows default to unpinned, the safe direction.

The budget and "keep opened originals" come from the settings page rather than
a constant, and are applied at startup rather than only on change — a cache
capped at 2 GB last session would otherwise spend this one filling to the
default. Turning off keeping leaves what is already cached readable: those
bytes are paid for, and refusing them would re-download images sitting right
there, including pinned ones.

Also removes a doubled `#[test]` introduced in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:12:01 +02:00

234 lines
8.5 KiB
Rust

//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5
//! 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.
///
/// Linux: `$XDG_CONFIG_HOME/darkroom/settings.json`, falling back to
/// `~/.config` (FR-PLAT-LIN-1) — the same resolution `SessionStore` does,
/// so the two files sit together and a user backing up one takes both.
pub fn open() -> Self {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
})
.join("darkroom");
Self::open_at(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");
}
}