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>
234 lines
8.5 KiB
Rust
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");
|
|
}
|
|
}
|