Keep originals on this device, by pin and by use
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

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>
This commit is contained in:
2026-08-11 21:12:01 +02:00
co-authored by Claude Opus 5
parent cd75e5a4c6
commit fa12afed18
22 changed files with 4032 additions and 86 deletions
+29 -6
View File
@@ -268,6 +268,16 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
// as "failed unexpectedly", losing the one piece of information that
// would explain the failure. Catch it and forward the message instead.
let panic_tx = tx.clone();
// Logging is unreliable here: android_logger delivers lines emitted
// during startup but nothing from this thread, so a failure that never
// sends is otherwise completely opaque. Reporting the last step reached
// through the channel puts it on screen, which is the one channel known
// to work.
let step_tx = tx.clone();
let step = |s: &str| {
let _ = step_tx.send(LoginMessage::Progress(s.to_string()));
};
step("thread started");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
// `enable_all()` also enables the signal driver, which wants to own
// process-wide signal handling and is not something a worker thread
@@ -280,12 +290,13 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(e.to_string()));
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
return;
}
};
step("runtime built");
run_login_flow(rt, tx, server);
run_login_flow(rt, tx, server, &step);
}));
if let Err(panic) = result {
@@ -308,16 +319,19 @@ fn run_login_flow(
rt: tokio::runtime::Runtime,
tx: std::sync::mpsc::Sender<LoginMessage>,
server: String,
step: &dyn Fn(&str),
) {
{
rt.block_on(async {
step("building http client");
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
Ok(c) => c,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(e.to_string()));
let _ = tx.send(LoginMessage::Failed(format!("http client: {e}")));
return;
}
};
step("requesting login flow");
log::info!("POST {server}/index.php/login/v2");
let flow = match auth::begin(&client, &server, "DarkRoom").await {
@@ -361,6 +375,8 @@ fn run_login_flow(
}
enum LoginMessage {
/// The last step the worker reached, for diagnosis when it dies silently.
Progress(String),
AwaitingApproval(String),
Success(Box<(dr_sync_nextcloud::AppCredentials, String)>),
Failed(String),
@@ -374,6 +390,9 @@ fn poll_channel(
) {
let timer = slint::Timer::default();
let ctl_for_cb = ctl.clone();
// Remembers the worker's last reported step, so a silent death names the
// point it got to rather than saying nothing.
let last_step = RefCell::new(String::from("nothing"));
timer.start(
slint::TimerMode::Repeated,
@@ -398,9 +417,10 @@ fn poll_channel(
// sending anything, which `catch_unwind` in
// spawn_login should now prevent — so say that the
// worker stopped rather than blaming the sign-in.
ctl.model
.borrow_mut()
.fail("the sign-in worker stopped without reporting why");
ctl.model.borrow_mut().fail(format!(
"the sign-in worker stopped after: {}",
last_step.borrow()
));
render(&w, ctl);
}
if let Some(t) = ctl.poll_timer.borrow().as_ref() {
@@ -412,6 +432,9 @@ fn poll_channel(
let mut done = false;
match msg {
LoginMessage::Progress(s) => {
*last_step.borrow_mut() = s;
}
LoginMessage::AwaitingApproval(url) => {
ctl.model.borrow_mut().await_approval(url);
}