Hand the photographer's place between devices

A place recorded on the tablet should be where the desktop opens.

Exchanged through `.darkroom-derived/place.json`, beside the thumbnail
shards and the catalog snapshot. Newest timestamp wins outright: unlike
the catalog this is replaced rather than merged, because two devices
cannot both be where the photographer is and so there is nothing of
theirs inside ours to preserve.

It still refuses to upload over a copy it could not read, for a smaller
version of the reason `sync_catalog` does: a record we have not compared
against may be the newer one, and overwriting it would move the other
device's photographer without ever having seen where they were.

Last in the pass, and its failures are logged rather than reported.
Everything else in that folder is *derived* -- a faster way to learn what
the device could work out for itself -- so losing it costs time. A place
is a fact only the other device knew, and losing it costs a scroll. A
sync that ran out of connectivity should spend what it had on the shards.

The full pass runs after a thumbnail sweep or when Sync is pressed,
neither of which happens on an ordinary launch -- so a handover would
arrive one launch late, which is one too many for a feature whose whole
claim is picking up where you stopped. `spawn_place_fetch` is the small
half: one GET of a few hundred bytes, started beside the scan.

And it can still be refused. A handover is welcome on the way in and
unwelcome once the photographer has started: a grid that jumped
elsewhere mid-scroll because a round trip finally landed would have lost
their place to the feature meant to keep it. Any scroll, scrub, scope
change, filter or opened photograph closes the latch, and a record
arriving after that is written to disk and takes effect next launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 20:40:22 +02:00
co-authored by Claude Opus 5
parent d44bffa4a8
commit 353382c07f
5 changed files with 566 additions and 33 deletions
+109
View File
@@ -215,6 +215,9 @@ pub struct LibraryController {
place_device: RefCell<String>,
/// Coalesces the writes a flick would otherwise make one of per event.
place_timer: RefCell<Option<slint::Timer>>,
/// Drains the launch-time fetch of the server's place. Held for the reason
/// every other timer here is: a `slint::Timer` stops when it is dropped.
place_fetch_timer: RefCell<Option<slint::Timer>>,
/// Whether a place is being applied right now.
///
/// A restore moves the scope, the filter and the viewport, and every one of
@@ -432,6 +435,7 @@ impl LibraryController {
place_store: RefCell::new(None),
place_device: RefCell::new(String::new()),
place_timer: RefCell::new(None),
place_fetch_timer: RefCell::new(None),
place_untouched: std::cell::Cell::new(true),
applying_place: std::cell::Cell::new(false),
viewing_trash: std::cell::Cell::new(false),
@@ -931,6 +935,12 @@ pub fn open(
// question is unanswered is how the last good copy gets destroyed. That
// gate is why the scan starts from the drain below rather than from here:
// the answer no longer arrives on the next line.
// TRACES: FR-UI-8
// Started beside the catalog open rather than after it, so the handover has
// a round trip's head start on the user deciding what to look at. It
// resolves nothing until the catalog is there — see `fetch_remote_place`.
fetch_remote_place(window, &ctl, &coll_ctl, &conn);
open_catalog_soon(window, ctl, coll_ctl, conn, filter, path);
}
@@ -2297,6 +2307,104 @@ fn adopt_catalog(
}
}
/// TRACES: FR-UI-8
/// Ask the server where the photographer was, and use it if they have not
/// started working yet.
///
/// # The two halves of a restore
///
/// The local record is applied in [`adopt_catalog`], synchronously and before
/// the first window is read: it is on this disk, it costs nothing, and it works
/// with the network down. This is the other half — the record another device
/// left — and it cannot be applied there because it has not arrived yet.
///
/// # Why it may be ignored when it does arrive
///
/// A handover is welcome on the way in and unwelcome once the photographer has
/// started. A grid that jumped somewhere else mid-scroll, because a round trip
/// finally landed, would be worse than never handing over at all — the user did
/// not ask for it, cannot see why it happened, and has lost their place to a
/// feature whose entire purpose is keeping it.
///
/// So `place_untouched` gates it: any scroll, scrub, scope change, filter or
/// opened photograph closes the latch. The record is still adopted onto disk by
/// the exchange in [`crate::derived_sync::sync_place`], so nothing is lost —
/// it simply takes effect at the next launch instead of this one.
///
/// Silent throughout. A first launch against a library nobody has recorded a
/// place for is the ordinary case, and being offline is not a failure of
/// anything the user asked for.
fn fetch_remote_place(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
conn: &Connection,
) {
if std::env::var_os("DARKROOM_NO_SYNC").is_some() {
return;
}
let rx = crate::derived_sync::spawn_place_fetch(conn.clone(), conn.account.root.clone());
let timer = slint::Timer::default();
let weak = window.as_weak();
let held = ctl.clone();
let coll = coll_ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
move || {
let Some(w) = weak.upgrade() else { return };
let got = match rx.try_recv() {
Ok(got) => got,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stop(&held.place_fetch_timer);
return;
}
};
stop(&held.place_fetch_timer);
let theirs = match got {
Ok(Some(place)) => place,
Ok(None) => return,
Err(e) => {
log::debug!("no place from the server: {e}");
return;
}
};
// Newer than what this device has, or there is nothing to learn.
//
// The borrow is scoped rather than dropped by hand: `apply_place`
// below reaches back into the controller, and a `Ref` still held
// across it is the shape a `RefCell` panic takes.
let adopted = match held.place_store.borrow().as_ref() {
Some(store) => store.adopt(&theirs),
None => false,
};
if !adopted {
return;
}
// On disk either way; on screen only if nobody has moved.
if !held.place_untouched.get() {
log::info!("a newer place arrived, and will be used at the next launch");
return;
}
// And only once the catalog is open — the ordinal it resolves to is
// a query against it. A record that lands first is left on disk,
// where `adopt_catalog` reads it.
if held.catalog.borrow().is_none() {
return;
}
log::info!("picking up where another device left off");
apply_place(&w, &held, &coll, &theirs);
},
);
*ctl.place_fetch_timer.borrow_mut() = Some(timer);
}
/// TRACES: FR-UI-8
/// What this device signs a place with.
///
@@ -3897,6 +4005,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
conn.account.root.clone(),
library::thumbs_dir(&conn.account),
catalog_path,
library::place_path(&conn.account),
scratch,
);