Files
DarkRoom/ui/dr-ui/src/library_ui/open.rs
T

1027 lines
46 KiB
Rust

//! 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<LibraryController>) {
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<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
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::<Library>()
.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::<Library>().set_library_open(true);
window.global::<Library>().set_library_scanning(true);
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
window
.global::<Library>()
.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::<Library>()
.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::<Library>().set_library_scanning(false);
window
.global::<Library>()
.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<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
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::<Library>().set_library_opening(true);
window
.global::<Library>()
.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::<Library>().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<CatalogOpen> {
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<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
conn: Connection,
filter: FormatFilter,
path: PathBuf,
) {
log::info!(
"scanning {} for {} format(s) → {}",
if conn.account.root.is_empty() {
"<account root>"
} else {
&conn.account.root
},
filter.iter().count(),
path.display()
);
window
.global::<Library>()
.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<AppWindow>,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
rx: Receiver<ScanMessage>,
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::<Library>().get_library_scanning() {
w.global::<Library>().set_library_scanning(false);
w.global::<Library>()
.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::<Library>().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::<Library>().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::<Library>().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::<Library>()
.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::<Library>().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::<Library>().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<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some((conn, filter)) = ctl.session.borrow().clone() else {
return;
};
window.global::<Library>().set_library_scanning(true);
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
window
.global::<Library>()
.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<LibraryController>,
catalog_path: &std::path::Path,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
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::<Library>().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<LibraryController>) {
// 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::<Library>().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::<Library>().set_library_scroll_to(anchor as i32);
w.global::<Library>()
.set_library_scroll_token(w.global::<Library>().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<LibraryController>,
catalog_path: &std::path::Path,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) -> 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<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
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<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.
///
/// 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<LibraryController>) {
*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");
}
}