//! Opening a library and bringing its catalog up to date: the launch path, //! the background scan, and the worker that checks the catalog file before //! either touches it. //! //! Split from the loading of one window over the catalog (`window`) because //! this is the coarser cycle around it — once per library open or rescan //! rather than once per scroll — and from the offline/pin bookkeeping //! (`offline`) it hands off to on a failure. See `docs/dev/code-health.md` //! CH-1. use std::path::PathBuf; use std::rc::Rc; use std::sync::mpsc::Receiver; use dr_catalog::Catalog; use dr_sync::{Account, AccountStore, Connection}; use dr_types::FormatFilter; use slint::ComponentHandle; use crate::library::{self, ScanMessage}; use crate::{AppWindow, Library, View}; use super::controller::{stop, LibraryController}; use super::offline::refresh_offline; use super::ratings_keywords::refresh_xmp_conflicts; use super::sync::{start_derived_sync, start_sweep}; use super::timeline::{apply_place, note_place}; use super::window::{load_window, window_start}; /// Reload the grid for the current scope and offset. /// /// The reload entry point for everything outside this module — a scope change, /// or a drop that altered the collection being shown. pub fn reload(window: &AppWindow, ctl: &Rc) { load_window(window, ctl); // TRACES: FR-UI-8 // The scope changed, or the collection under it did. Either way this is the // entry point every such change comes through, which is why the record is // taken here rather than at each of the callers. note_place(window, ctl); } /// Open a library: show the grid, start a scan, then fill in thumbnails. /// /// Called from the launch screen's "Open library" button — the callback that /// until now only logged its intent. pub fn open( window: &AppWindow, ctl: Rc, coll_ctl: Rc, store: &AccountStore, account: Account, ) { // The credential is fetched only where the connector wants one; a folder // library has none, and asking the keyring for it would fail the one // backend that needs nothing. let conn = match store.connection(&account, crate::remote::needs_secret(&account)) { Ok(c) => c, Err(e) => { window .global::() .set_library_error(format!("credentials: {e}").into()); window.set_active_view(View::Library); return; } }; let filter = account.format_filter(); *ctl.session.borrow_mut() = Some((conn.clone(), filter.clone())); // TRACES: FR-UI-8 // Where this library's position is kept, and what this device signs it // with. Before the catalog opens, because `adopt_catalog` is what reads the // record back and it is the next thing to run. *ctl.place_store.borrow_mut() = Some(crate::place::PlaceStore::open_at(library::place_path( &conn.account, ))); *ctl.place_device.borrow_mut() = device_id(&conn.account); // A fresh library is one nobody has moved in yet, so a place arriving from // another device may still be applied — see `place_untouched`. ctl.place_untouched.set(true); window.set_active_view(View::Library); window.global::().set_library_open(true); window.global::().set_library_scanning(true); window .global::() .set_library_error(slint::SharedString::new()); window .global::() .set_library_status("Starting…".into()); // Always visible: two folders one letter apart are easy to confuse, and a // scan of the wrong one is indistinguishable from a broken scan. window .global::() .set_library_root_label(library_root_label(&conn.account).into()); // An empty filter would walk the whole tree and match nothing, which looks // exactly like a broken scan. Say so instead. if filter.is_empty() { window.global::().set_library_scanning(false); window .global::() .set_library_error("No formats selected — tick at least one.".into()); return; } let path = library::catalog_path(&conn.account); // Before the scan, not after it: the grid can be filled from disk now and // the scan is only ever going to add to it. // // And it is the gate on the scan, not merely a prelude to it — a damaged // catalog has a question on screen, and a scan writing into it while that // 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); } /// Open the catalog on a worker, then show it and start the scan. /// /// # Why this is not `show_catalog_now` on the spot /// /// It was, and it was the largest blocking call on the launch path. /// [`Catalog::open_verified`] runs `PRAGMA quick_check`, which reads **every /// page** of the database, and then `Catalog::open`, which takes a full copy of /// the file before a migration and rewrites its structure. On a 50,000-image /// library that is tens of megabytes of I/O, and all of it happened before the /// window had painted anything. /// /// On Android that is not a stutter but an ANR. `dr_ui::run` is called from /// `android_main` and does not reach `window.run()` — the first `poll_events`, /// and so the first time anything drains the activity's input channel — until /// every line above it has finished. Five seconds of that and the system offers /// to kill the app. /// /// [`crate::recovery_ui`] and `recovery`'s own module documentation both say the /// check belongs "at startup, where a failure has a user in front of it who can /// answer a question". That was always the intent; this is what makes it true, /// because the question can only be asked once there is an interface to ask it /// in. /// /// # What the user sees meanwhile /// /// `library-opening`, which the grid's empty state draws as "Opening the /// library…" rather than as "Scanning…". They are different answers — one is /// reading a file on this device, the other is walking a tree over the network /// — and the grid already refuses to conflate the two states it had. /// /// `library-scanning` stays true throughout, which is what keeps the gate above /// from being merely advisory: the header hides Rescan while it is set, so /// there is no button that could start a scan into a catalog this has not /// finished checking. fn open_catalog_soon( window: &AppWindow, ctl: Rc, coll_ctl: Rc, conn: Connection, filter: FormatFilter, path: PathBuf, ) { // Already open: a library being re-opened within one session, which is the // case `show_catalog_now` short-circuited. There is nothing to wait for and // nothing to say, so the scan starts immediately as it always did. if ctl.catalog.borrow().is_some() { begin_scan(window, &ctl, &coll_ctl, conn, filter, path); return; } window.global::().set_library_opening(true); window .global::() .set_library_status("Reading the catalog on this device…".into()); let rx = spawn_catalog_open(path.clone()); let weak = window.as_weak(); let held = ctl.clone(); let timer = slint::Timer::default(); timer.start( slint::TimerMode::Repeated, // Tighter than the scan's 120 ms: this one lands once, and everything // the grid can show is waiting behind it. std::time::Duration::from_millis(30), move || { let Some(w) = weak.upgrade() else { return }; let got = match rx.try_recv() { Ok(got) => got, Err(std::sync::mpsc::TryRecvError::Empty) => return, // The worker died without answering — a panic inside SQLite, // realistically. Treated as "no catalog to show", which is what // the synchronous path did with any error it could not // classify: the scan is the thing that has to work. Err(std::sync::mpsc::TryRecvError::Disconnected) => { CatalogOpen::Failed("the catalog worker stopped without answering".into()) } }; stop(&held.catalog_timer); w.global::().set_library_opening(false); match got { CatalogOpen::Opened(cat) => { adopt_catalog(&w, &held, &coll_ctl, cat); begin_scan( &w, &held, &coll_ctl, conn.clone(), filter.clone(), path.clone(), ); } CatalogOpen::Corrupt(detail) => { // No scan, and this is the whole reason the scan waits for // this answer: `Catalog::open` succeeds on a file whose // header survived, so a scan would write ETags and image // rows into damaged pages while the user is still reading // the question — turning a file that had a backup into one // where the backup is the only copy left. // // `offer` clears the scanning state itself, for exactly // that reason, so nothing here does it twice. crate::recovery_ui::offer(&w, &path, &detail); } CatalogOpen::Failed(why) => { // Not surfaced: this is a first run more often than it is // anything else, the empty state already says the scan is // running, and an error here would contradict a scan that // is working perfectly. If the scan fails too, it reports // for both of them. log::info!("no catalog to show before the scan: {why}"); begin_scan( &w, &held, &coll_ctl, conn.clone(), filter.clone(), path.clone(), ); } } }, ); *ctl.catalog_timer.borrow_mut() = Some(timer); } /// What the catalog worker found. enum CatalogOpen { /// Checked, opened, migrated forward and backfilled. Opened(Catalog), /// `PRAGMA quick_check` failed. Carries SQLite's own words, because the /// message the user is shown is the diagnosis rather than a paraphrase. Corrupt(String), /// Anything else: no catalog file yet, a permission, a locked database. Failed(String), } /// Check, open and migrate the catalog on a thread of its own. /// /// `Catalog` holds a `rusqlite::Connection`, which is `Send` and not `Sync` — /// exactly the shape that can be built on a worker and handed over once, which /// is what this does. Nothing else touches the file while it runs: this is the /// only opener on the launch path, and the scan that opens its own connection /// does not start until the answer has landed. fn spawn_catalog_open(path: PathBuf) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let started = std::time::Instant::now(); let message = match Catalog::open_verified(&path) { Ok(cat) => { // The number this change exists for. Worth an `info` line on // every launch: it is the one figure that says whether a slow // start is the catalog or something else, and it is not // measurable from anywhere else. log::info!( "catalog checked and opened in {} ms", started.elapsed().as_millis() ); CatalogOpen::Opened(cat) } Err(dr_catalog::CatalogError::Corrupt { detail }) => CatalogOpen::Corrupt(detail), Err(e) => CatalogOpen::Failed(e.to_string()), }; let _ = tx.send(message); }); rx } /// Start the scan that brings the catalog up to date. /// /// Split out of [`open`] because it no longer runs there — see /// [`open_catalog_soon`] — and shared with the two paths that reach it once the /// catalog has been answered for. fn begin_scan( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, conn: Connection, filter: FormatFilter, path: PathBuf, ) { log::info!( "scanning {} for {} format(s) → {}", if conn.account.root.is_empty() { "" } else { &conn.account.root }, filter.iter().count(), path.display() ); window .global::() .set_library_status("Starting…".into()); let rx = library::spawn_scan( conn.clone(), conn.account.root.clone(), filter, path.clone(), ); drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); } /// Drain scan progress on the UI thread. fn drain_scan( weak: slint::Weak, ctl: Rc, coll_ctl: Rc, rx: Receiver, catalog_path: std::path::PathBuf, ) { let timer = slint::Timer::default(); let ctl_cb = ctl.clone(); // Indeterminate for as long as it runs: a recursive walk discovers its own // extent, so the count it reports is what it has *found*, never a fraction // of what there is (FR-CAT-1). // // Named after the folder, because two accounts or two roots produce rows // that are otherwise identical. let title = match ctl.session.borrow().as_ref() { Some((c, _)) if !c.account.root.is_empty() => { format!("Scanning {}", c.account.root) } _ => "Scanning the library".to_string(), }; let job = ctl.activity.begin(crate::activity::Kind::Scan, title); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(120), move || { let Some(w) = weak.upgrade() else { return }; let ctl = &ctl_cb; loop { let msg = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { // A worker that died without sending must not leave // the screen on "Scanning…" forever. if w.global::().get_library_scanning() { w.global::().set_library_scanning(false); w.global::() .set_library_error("scan ended unexpectedly".into()); job.fail("ended unexpectedly"); } stop(&ctl.scan_timer); return; } }; match msg { ScanMessage::Progress { directories, pruned, images, } => { // Pruned folders are reported separately rather than // folded into the total: they are the ETag walk paying // off, and hiding them makes an incremental rescan look // identical to a full one. let status = if pruned > 0 { format!("{directories} folders · {pruned} unchanged · {images} images") } else { format!("{directories} folders · {images} images") }; job.detail(status.clone()); w.global::().set_library_status(status.into()); } ScanMessage::Done { found, total, pruned, elapsed_ms, judgements, } => { log::info!( "scan complete: {total} images ({found} listed, \ {pruned} folders unchanged, {judgements} judgements \ taken in) in {elapsed_ms} ms" ); w.global::().set_library_scanning(false); // A completed scan is the strongest possible evidence // the server is reachable, so it clears offline mode // without needing a probe of its own. if ctl .reachability .borrow_mut() .mark_reachable(std::time::Instant::now()) { log::info!("back online"); } // TRACES: FR-PLAT-AND-2 // And it is the only evidence that clears a lost root, // for the same reason: the walk began by listing the // root, so a scan that finished is a root that opened. // The rows it marked offline are restored one at a // time by `library::persist`, as each file is listed // again — this only stops the banner claiming what is // no longer true. if ctl.root_lost.borrow_mut().take().is_some() { log::info!("library folder is readable again"); } refresh_offline(&w, ctl); // TRACES: FR-CAT-13 // The pull may have found an `.xmp` that disagrees // with the catalog; the settings page offers the // reload, and this is what tells it how many. refresh_xmp_conflicts(&w, ctl); // An incremental rescan lists almost nothing, so // reporting the listed count would read as "0 images" // on a library that is simply up to date. let secs = elapsed_ms as f64 / 1000.0; let status = if pruned > 0 && found == 0 { format!("up to date · {total} images · {secs:.1}s") } else if pruned > 0 { format!("{found} new or changed · {total} images · {secs:.1}s") } else { format!("{total} images · {secs:.1}s") }; // TRACES: FR-CAT-8 | FR-NC-9 // Said out loud, because a grid that silently gains // three hundred stars is indistinguishable from one // that has gone wrong — and because for as long as // this number was structurally zero, the photographer // had no way to tell that a cull made on another // device had failed to arrive. let status = if judgements > 0 { format!("{status} · {judgements} from other devices") } else { status }; job.finish(status.clone()); w.global::().set_library_status(status.into()); // Usually already open — `show_catalog_now` opened it // before this scan started, so the grid has been // showing the previous run's catalog all along and the // rows this scan found are new rows in that same file. // Only a first run, where there was nothing to open, // reaches the second arm. let opened = if ctl.catalog.borrow().is_some() { Ok(()) } else { Catalog::open(&catalog_path).map(|cat| { *ctl.catalog.borrow_mut() = Some(cat); }) }; match opened { Ok(()) => { // The sidebar is built before the grid: the // grid's badges read collection membership, and // the tree is where the catalog handle first // becomes available to it. { let borrow = ctl.catalog.borrow(); if let Some(cat) = borrow.as_ref() { crate::collections_ui::refresh_tree(&w, &coll_ctl, cat); } } load_window(&w, ctl); // Take the server's shards and catalog *now*, // before the sweep: the rows they key on exist // from this moment, and on a fresh device // every thumbnail, face and collection a peer // has already made is on the server. Waiting // for the sweep — hours on a large library — // meant re-deriving all of it here first. In // steady state this is one listing. start_derived_sync(&w, ctl); // Everything the grid did not touch: the rest // of the library gets a thumbnail and a date, // so the timeline describes all of it rather // than the part that was scrolled past. start_sweep(&w, ctl); } Err(e) => w .global::() .set_library_error(format!("opening catalog: {e}").into()), } stop(&ctl.scan_timer); return; } ScanMessage::Failed { message, offline, lost_root, } => { log::warn!("scan failed: {message}"); w.global::().set_library_scanning(false); // Recorded as a failure even where it is only the // connection: the grid's offline banner says the server // is unreachable, and this says which piece of work // stopped because of it. job.fail(message.clone()); if lost_root { // TRACES: FR-PLAT-AND-2 | FR-CAT-9 // The library folder itself could not be opened — // a share withdrawn, an unplugged drive, and in // time a revoked document-tree grant. The worker // has already marked every row under this root // offline and deleted none of them; this is the // half the user sees. // // Tested first because it is also true that the // library is unreachable, and the generic answer // would be reached first and be less useful. *ctl.root_lost.borrow_mut() = Some(message); refresh_offline(&w, ctl); // Same reason as the offline arm below: without // this a launch that began with a revoked grant // shows an empty grid, which is the one impression // this whole path exists to avoid. open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl); } else if offline { // Not an error state. The catalog from the last // successful scan is still on disk and still // accurate for everything already indexed, so the // grid keeps working — it simply cannot learn about // anything added on the server since. ctl.reachability .borrow_mut() .mark_unreachable(message, std::time::Instant::now()); refresh_offline(&w, ctl); // Show whatever the catalog holds. Without this the // grid stays empty on a launch that began offline, // which is precisely the case offline mode exists // for. open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl); } else { w.global::().set_library_error(message.into()); } stop(&ctl.scan_timer); return; } } } }, ); *ctl.scan_timer.borrow_mut() = Some(timer); } /// Start a scan against the configured library. /// /// Shared by the rescan button and by the offline banner's retry, which are /// the same operation: a scan is the only request that both proves the server /// is reachable and brings the catalog up to date. Keeping them one function /// is what stops "retry" from quietly becoming a weaker probe than "rescan". pub(crate) fn start_rescan( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, ) { let Some((conn, filter)) = ctl.session.borrow().clone() else { return; }; window.global::().set_library_scanning(true); window .global::() .set_library_error(slint::SharedString::new()); window .global::() .set_library_status("Rescanning…".into()); let path = library::catalog_path(&conn.account); let rx = library::spawn_scan( conn.clone(), conn.account.root.clone(), filter, path.clone(), ); drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); } /// TRACES: FR-CAT-9 /// Show the catalog after a scan that could not reach the server. /// /// The whole point of offline mode: a scan is how the grid normally gets its /// catalog handle, so a failed one previously left the library empty even /// though a complete catalog was sitting on disk from the last successful run. /// The images are all still there, their thumbnails are in the shards, and /// rating and collecting them are local writes. /// /// The sweep is deliberately **not** started — it exists to fetch headers over /// the network, so offline it would do nothing but fail once per image. fn open_catalog_for_offline( window: &AppWindow, ctl: &Rc, catalog_path: &std::path::Path, coll_ctl: &Rc, ) { if ctl.catalog.borrow().is_some() { // Already open — a rescan that failed, rather than a launch that // began offline. The grid is showing the catalog already. load_window(window, ctl); return; } match Catalog::open(catalog_path) { Ok(cat) => { crate::collections_ui::refresh_tree(window, coll_ctl, &cat); *ctl.catalog.borrow_mut() = Some(cat); load_window(window, ctl); } Err(e) => { // No catalog and no server. This is the one genuinely empty case: // a first run that never reached the server has nothing indexed. log::warn!("offline with no local catalog: {e}"); window.global::().set_library_error( "Offline, and this library has not been scanned on this device yet.".into(), ); } } } /// How long a run of geometry changes has to stop for before the window is /// reloaded against it. /// /// Long enough to swallow a whole gesture's worth of steps, short enough that a /// single deliberate step still feels immediate. const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140); /// Reload the window once the grid's geometry has stopped changing. /// /// # Why this is deferred when a scroll is not /// /// A pinch is not one zoom step, it is a stream of them, and every step /// changes both the column count and the capacity — two reports. Each report /// used to re-query the catalog, rebuild all 360 rows of the model, re-read the /// badges and ratings for every one of them and spawn a thumbnail batch, /// synchronously, on the thread that is trying to draw the frame. Twice per /// step. That is why zooming juddered while scrolling the same grid is smooth: /// a scroll reloads a few times per screenful, a zoom reloaded twice a frame. /// /// None of it is urgent, because none of it is about *which* photographs are on /// screen. A column change moves the cells and a zoom resizes them, but the /// window holds the same images either way — the model already has them, and /// the cells re-flow from `columns` and `cell-size` without Rust being involved /// at all. What the reload actually recomputes is which cells begin a row, so /// the month headings land in the right places, and which thumbnail size class /// to ask for now. Both can wait for the gesture to finish. /// /// # The anchor /// /// Captured on the *first* report of a run rather than read when the timer /// fires. As the grid re-flows, the viewport keeps its pixel offset while the /// rows move underneath it, so the view drifts and reports its drift — reading /// the anchor at the end would faithfully return to wherever it had wandered /// to. Taking it at the start returns to the photograph the user was actually /// looking at when they began the gesture. pub(super) fn schedule_reload(window: &AppWindow, ctl: &Rc) { // Only the first report of a run sets it; the rest of the run reuses it. if ctl.pending_anchor.get().is_none() { ctl.pending_anchor.set(Some(ctl.resume_at.get())); } let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || { let Some(w) = weak.upgrade() else { return }; let anchor = ctl_cb.pending_anchor.take().unwrap_or(0); if !w.get_library_visible() { return; } // The window re-centred on the anchor, and the viewport sent back to // it: cells are drawn at their absolute place in the library, so a // change to `columns` moves every one of them and a viewport left // where it was would be pointing at rows the window no longer covers. let window_size = *ctl_cb.window.borrow(); let total = w.global::().get_library_total().max(0) as usize; *ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total); load_window(&w, &ctl_cb); w.global::().set_library_scroll_to(anchor as i32); w.global::() .set_library_scroll_token(w.global::().get_library_scroll_token() + 1); }); // Replacing the slot drops the previous timer, which is what makes this // coalesce: only the last report of a run lives long enough to fire. *ctl.geometry_timer.borrow_mut() = Some(timer); } /// Show what the catalog already holds, without waiting for the scan. /// /// **Blocking, and no longer on the launch path.** [`open_catalog_soon`] is /// what a launch goes through; this is what [`crate::recovery_ui`] goes through /// once the user has answered the damage question, where the event loop is /// already running, the file has just been replaced under a `forget_catalog`, /// and there is a `recovery-busy` state on screen saying so. Same reasoning as /// `recovery_ui::answer` gives for doing the file copy itself in place: nothing /// else is on screen to block, and a worker would buy a spinner and cost the /// guarantee that nothing else touches the file while it is being replaced. /// /// # Why a launch should not be a scan /// /// A launch does not have to discover the library. The catalog from the last /// run is on disk, complete, with its thumbnails in the shards beside it — /// which is precisely the state [`open_catalog_for_offline`] already relies on /// when the server cannot be reached. Every launch that *could* reach the /// server threw that away and sat on "Scanning…" over an empty grid for as long /// as a recursive WebDAV walk of the whole tree takes. On a real library, and /// especially on a phone's connection, that walk is the entire startup time, /// spent hiding a grid that was ready before it began. /// /// The scan still runs and still replaces this the moment it lands. What it no /// longer does is gate the first paint on the network. /// /// Silent when there is no catalog yet: that is a genuine first run, the empty /// state already says "Scanning…", and an error here would contradict a scan /// that is working perfectly. `Catalog::open` creates the file in that case, so /// what the grid reads is an empty catalog rather than a failure. /// Returns whether it is safe to go on and scan. /// /// `false` means the catalog is damaged and the recovery question is up. The /// caller must not start a scan on that answer: `Catalog::open` succeeds on a /// file whose header survived, so the scan would write ETags and image rows /// into damaged pages while the user is still reading the question — turning a /// file that had a backup into one where the backup is the only copy left. pub(crate) fn show_catalog_now( window: &AppWindow, ctl: &Rc, catalog_path: &std::path::Path, coll_ctl: &Rc, ) -> bool { if ctl.catalog.borrow().is_some() { return true; } // Verified rather than plain: this is a moment where a full check is // affordable and there is a user in front of it who can answer the // question — they have just answered one. See `dr_catalog::recovery` for // why it is not on every open. let cat = match Catalog::open_verified(catalog_path) { Ok(cat) => cat, Err(dr_catalog::CatalogError::Corrupt { detail }) => { crate::recovery_ui::offer(window, catalog_path, &detail); return false; } Err(e) => { // Not surfaced: the scan is the thing that has to work, and it is // still running. If it fails too, it reports for both of them. log::info!("no catalog to show before the scan: {e}"); return true; } }; adopt_catalog(window, ctl, coll_ctl, cat); true } /// Take an opened catalog into the interface. /// /// The UI half of opening a catalog, separated from the opening itself so that /// [`open_catalog_soon`] — which does the opening on a worker — and /// [`show_catalog_now`] — which still does it in place, for /// [`crate::recovery_ui`], where the event loop is already running and the file /// has just been replaced — describe the grid the same way. /// /// Everything here is UI-thread work by necessity: it fills the sidebar and the /// grid model. fn adopt_catalog( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, cat: Catalog, ) { // Before anything else can see this catalog, and exactly once per open — // the callers' early return on an already-open catalog is what makes it // once. The job queue is durable, so a run that was killed mid-job left its // row marked `Running` with nobody holding it; recovery hands those back to // be resumed rather than lost, and drops jobs naming photographs that have // since been deleted. // // Here rather than wherever a runner starts, because there is no owner // column in `jobs`: a second recovery pass while a worker held a claim // would take that claim away from it. match dr_catalog::runner::recover(cat.connection()) { Ok(r) if r.did_anything() => log::info!( "job queue: {} interrupted job(s) resumed, \ {} for deleted photographs dropped", r.reclaimed, r.reaped ), Ok(_) => {} // Not surfaced. The queue is rebuildable like everything else in the // catalog, and a grid that refuses to open because a background queue // could not be tidied is the worse failure by some way. Err(e) => log::warn!("job queue could not be recovered: {e}"), } // The sidebar before the grid, because the grid's badges read collection // membership — the same order the scan's completion uses. crate::collections_ui::refresh_tree(window, coll_ctl, &cat); *ctl.catalog.borrow_mut() = Some(cat); // TRACES: FR-UI-8 // Where the photographer was, before the first window is read rather than // after. The scope and the filter both change what the grid's query // returns, so restoring them afterwards would mean reading the top of the // whole library, drawing it, and then reading again — a visible jump on // every launch, and on a remote library a screenful of thumbnails fetched // for photographs nobody asked to see. // // The sidebar is refreshed above rather than below for the same ordering // reason: a scope is restored by naming a row of a tree that has to exist. let stored = ctl.place_store.borrow().as_ref().and_then(|s| s.load()); match stored { Some(place) => apply_place(window, ctl, coll_ctl, &place), None => load_window(window, ctl), } } /// 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, coll_ctl: &Rc, 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. /// /// The thumbnail store's client id, which is already this device's name among /// the clients sharing a library — it is what qualifies a shard's remote /// filename. Reusing it means the place and the shards a machine publishes /// carry one name in `.darkroom-derived/`, rather than two that have to be /// correlated by hand. /// /// Empty where the store will not open. The field is informational, so an /// unsigned place is worth strictly more than no place at all. fn device_id(account: &Account) -> String { match dr_thumbs::ThumbStore::open(&library::thumbs_dir(account)) { Ok(store) => store.client_id().to_string(), Err(e) => { log::debug!("no device id for the place record: {e}"); String::new() } } } /// Drop the open catalog, so the next `show_catalog_now` opens the file /// again rather than returning early. /// /// Only recovery needs this, and it needs it for a specific reason: the file /// under that connection has been replaced. A handle to the catalog that was /// there before is a handle to a file that no longer has a name, and every /// read through it would return the damaged pages the recovery just moved out /// of the way. pub(crate) fn forget_catalog(ctl: &Rc) { *ctl.catalog.borrow_mut() = None; } /// The line in the library header that says what is being catalogued. /// /// For an account with a user, the user and the chosen subtree, or the /// whole account when none was chosen. For a folder there is no user and /// the endpoint is the library, so the folder's own name: `library`, or /// `library/2026` when narrowed — never " · whole account", which is a /// sentence about a server said of a directory. fn library_root_label(account: &Account) -> String { if account.login.is_empty() { let folder = std::path::Path::new(&account.endpoint) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| account.endpoint.clone()); if account.root.is_empty() { folder } else { format!("{folder}/{}", account.root) } } else if account.root.is_empty() { format!("{} · whole account", account.user_id) } else { format!("{}/{}", account.user_id, account.root) } } #[cfg(test)] mod tests { use super::*; #[test] fn the_header_names_a_folder_library_by_its_folder() { let mut folder = Account::new("folder", "/var/tmp/dr-demo/library"); assert_eq!(library_root_label(&folder), "library"); folder.root = "2026".into(); assert_eq!(library_root_label(&folder), "library/2026"); let mut cloud = Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan"); assert_eq!(library_root_label(&cloud), "duncan · whole account"); cloud.root = "PhotosRaw".into(); assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw"); } }