Files
DarkRoom/ui/dr-ui/src/library_ui/offline.rs
T
dtourolle 1e472fd251 Move the library's routes and status onto its own Slint global
The scan/opening/status/error lines, offline mode and its retry, pinning a
collection offline, the sync and thumbnail-sweep state, and the callbacks
that route the grid to a rescan, a sync, another library, a panorama merge
or an export — the last of what AppWindow still carried under the
library- prefix — move onto the `Library` global started earlier on this
branch. Rust reaches them through window.global::<Library>() rather than
window.set_/get_/on_/invoke_ on the root.

library-visible is the one name that stays: it is computed from
active-page and active-view, the shell's own routing state, which a
global cannot read. AppWindow now declares no other library- property or
callback.
2026-09-20 22:22:55 +02:00

718 lines
27 KiB
Rust

//! Keeping a collection's originals on this device: the offline prompt, the
//! pin fetch, and the connectivity banner every worker in this directory
//! paints through [`refresh_offline`].
//!
//! Split out because it is one coherent user question — "what of this is on
//! my device, and what would keeping or releasing it cost" — with its own
//! catalog queries, distinct from loading a window over the grid or scanning
//! the tree. See `docs/dev/code-health.md` CH-1.
use std::rc::Rc;
use dr_catalog::Catalog;
use slint::ComponentHandle;
use crate::library;
use crate::{AppWindow, Library};
use super::controller::{stop, LibraryController};
/// TRACES: FR-NC-6a | FR-NC-6c
/// What a collection would cost to take with you, and what it is holding now.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct OfflineSummary {
/// Photographs in the collection and its children, deduplicated.
total: usize,
/// Of those, how many have their original on this device.
held: usize,
/// Disk those originals occupy — what releasing would give back.
held_bytes: u64,
/// What the rest would cost to fetch, from the sizes the scan recorded.
/// Zero where nothing has been stat-ed yet, which reads as "unknown"
/// rather than "free" in the label built from it.
missing_bytes: u64,
}
impl OfflineSummary {
fn missing(self) -> usize {
self.total.saturating_sub(self.held)
}
}
/// Read the offline summary for a set of images.
///
/// One query with the ids inlined as placeholders — the same shape
/// [`collection_images`] uses, and for the same reason: a collection is tens to
/// thousands of rows, and a round trip per photograph to answer one dialogue is
/// not a trade worth making.
fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary {
if images.is_empty() {
return OfflineSummary::default();
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
// `tier_actual`, never `tier_desired`: the question is what can be opened
// on the aeroplane, and a pin whose download has not run yet answers no.
let sql = format!(
"SELECT count(*),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0
ELSE coalesce(i.file_size, 0) END), 0)
FROM images i
LEFT JOIN image_cache c ON c.image_id = i.id
WHERE i.id IN ({placeholders})"
);
let mut params: Vec<rusqlite::types::Value> = vec![rusqlite::types::Value::Integer(
dr_types::Tier::Original.stored(),
)];
params.extend(
images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64)),
);
catalog
.connection()
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
Ok(OfflineSummary {
total: r.get::<_, i64>(0)? as usize,
held: r.get::<_, i64>(1)? as usize,
held_bytes: r.get::<_, i64>(2)? as u64,
missing_bytes: r.get::<_, i64>(3)? as u64,
})
})
.unwrap_or_else(|e| {
log::debug!("reading offline summary: {e}");
OfflineSummary::default()
})
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// Ask what should happen to a collection's local copies.
///
/// Both answers are expensive — one commits the device to a download of
/// gigabytes, the other deletes gigabytes it already holds — so this is a
/// question rather than a toggle, and the counts and sizes go in the buttons
/// where they are read *before* the tap rather than in a second dialogue after
/// it (FR-NC-6c: an operation requiring absent data says so, with the size,
/// before starting).
pub(super) fn open_offline_prompt(
window: &AppWindow,
ctl: &Rc<LibraryController>,
id: dr_types::CollectionId,
) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// Descendants, matching what the grid shows when scoped to this row:
// keeping a parent whose children hold the photographs must keep the
// photographs, or the answer would appear to do nothing.
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window
.global::<Library>()
.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
let summary = offline_summary(catalog, &images);
let name = dr_catalog::collections::tree(catalog.connection())
.ok()
.and_then(|rows| {
rows.into_iter()
.find(|r| r.collection.id == id)
.map(|r| r.collection.name)
})
.unwrap_or_else(|| "This collection".to_string());
ctl.offline_target.set(Some(id));
window.set_offline_prompt_title(name.as_str().into());
window.set_offline_prompt_detail(
if summary.total == 0 {
"Nothing in here yet. Put some photographs in it first.".to_string()
} else if summary.held == summary.total {
format!(
"All {} on this device · {}",
summary.total,
crate::activity::describe_bytes(summary.held_bytes)
)
} else {
format!(
"{} photographs · {} already on this device",
summary.total, summary.held
)
}
.as_str()
.into(),
);
window.set_offline_prompt_keep_label(
// The size is named where the scan has recorded one. Where it has not,
// the label says what it will do and not what it will cost, which is
// honest — a "0 B" download would be a lie about a gigabyte.
if summary.missing_bytes > 0 {
format!(
"Download {} · {}",
summary.missing(),
crate::activity::describe_bytes(summary.missing_bytes)
)
} else if summary.missing() > 0 {
format!("Download {}", summary.missing())
} else {
"Everything is already here".to_string()
}
.as_str()
.into(),
);
window.set_offline_prompt_release_label(
format!(
"Remove {} local copies · frees {}",
summary.held,
crate::activity::describe_bytes(summary.held_bytes)
)
.as_str()
.into(),
);
window.set_offline_prompt_can_keep(summary.missing() > 0);
window.set_offline_prompt_can_release(summary.held > 0);
window.set_offline_prompt_busy(window.global::<Library>().get_library_pin_total() > 0);
}
/// Close the offline question without answering it.
pub(super) fn close_offline_prompt(window: &AppWindow, ctl: &Rc<LibraryController>) {
ctl.offline_target.set(None);
// The title is what the prompt's visibility is bound to: one fact, so a
// dialogue cannot be up with nothing written on it.
window.set_offline_prompt_title(slint::SharedString::new());
}
/// TRACES: FR-NC-6a
/// Keep a collection on this device: record the pin, then start the transfer.
///
/// Two separate things, and keeping them separate is what makes the answer feel
/// immediate — recording the intent is a local catalog write that completes at
/// once, and downloading the bytes may take a very long time. The pin also
/// survives the app being closed halfway through, which is what makes the
/// transfer resumable rather than something to start again.
pub(super) fn keep_collection_offline(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some(id) = ctl.offline_target.get() else {
return;
};
let Some(cache) = ctl.cache() else {
window
.global::<Library>()
.set_library_error("No cache directory for this library.".into());
return;
};
let images = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window
.global::<Library>()
.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
if images.is_empty() {
window
.global::<Library>()
.set_library_error("Nothing in that collection to keep offline.".into());
return;
}
if let Err(e) = cache.pin(catalog.connection(), &images) {
window
.global::<Library>()
.set_library_error(format!("pinning: {e}").into());
return;
}
images
};
log::info!("pinned {} image(s) for offline use", images.len());
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
if ctl.scope.borrow().as_ref() == Some(&id) {
window.global::<Library>().set_library_scope_pinned(true);
}
close_offline_prompt(window, ctl);
start_pin_fetch(window, ctl);
refresh_collection_tree(window, ctl, coll_ctl);
}
/// TRACES: FR-NC-6a | FR-NC-6b
/// Give the disk back: release the pin *and* delete the originals it held.
///
/// Deliberately destructive, where unpinning alone is not. "Remove the local
/// copies" is asked by someone whose device is full, and answering it by
/// withdrawing a promise and leaving the gigabytes for a future eviction to
/// notice is not an answer. Nothing is lost that cannot be fetched again: the
/// originals are on the server, and the ratings, the edit graph and the
/// thumbnails are all untouched — they are authoritative and small.
pub(super) fn release_collection_offline(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some(id) = ctl.offline_target.get() else {
return;
};
let Some(cache) = ctl.cache() else {
window
.global::<Library>()
.set_library_error("No cache directory for this library.".into());
return;
};
let released = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window
.global::<Library>()
.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
let outcome = match cache.release(catalog.connection(), &images) {
Ok(r) => r,
Err(e) => {
window
.global::<Library>()
.set_library_error(format!("removing local copies: {e}").into());
return;
}
};
// TRACES: FR-NC-6c
// On a placeholder library the bookkeeping above owns no files, so it
// freed nothing — the originals are materialised in the library folder
// and only the sync client may take them back. Asking it to is what
// makes unpinning actually return the disk, and it must be a
// dehydration rather than a delete: removing a file inside a synced
// tree propagates to the server (ARCH §9.0a).
//
// Fire and forget: it is per-file work over a socket, the user has
// already been told the pin is withdrawn, and a client that refuses
// leaves the content where it is at no cost but disk.
if let Some(conn) = ctl.session() {
let path = library::catalog_path(&conn.account);
std::mem::drop(library::spawn_dehydrate(conn, path, images));
}
outcome
};
let (count, freed) = released;
log::info!(
"released {count} image(s), freeing {}",
crate::activity::describe_bytes(freed)
);
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
window.global::<Library>().set_library_status(
format!(
"Removed local copies · {} freed",
crate::activity::describe_bytes(freed)
)
.as_str()
.into(),
);
if ctl.scope.borrow().as_ref() == Some(&id) {
window.global::<Library>().set_library_scope_pinned(false);
}
// A download that was still running for this collection has just had its
// reason withdrawn; the worker checks `pending_pins` per file, so it stops
// finding work rather than being killed.
window.global::<Library>().set_library_pin_total(0);
window.global::<Library>().set_library_pin_done(0);
close_offline_prompt(window, ctl);
refresh_local_count(window, ctl);
refresh_collection_tree(window, ctl, coll_ctl);
}
/// Redraw the sidebar, so the trays reflect what was just kept or released.
fn refresh_collection_tree(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let borrow = ctl.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
crate::collections_ui::refresh_tree(window, coll_ctl, catalog);
}
}
/// Every image in the given collections, deduplicated.
///
/// An image in both a parent and a child is one photograph and must be pinned
/// once, exactly as the grid draws it once.
pub(super) fn collection_images(
catalog: &Catalog,
ids: &[dr_types::CollectionId],
) -> Vec<dr_types::ImageId> {
if ids.is_empty() {
return Vec::new();
}
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT DISTINCT image_id FROM collection_members
WHERE collection_id IN ({placeholders})"
);
let params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("listing collection images: {e}");
Vec::new()
}
}
}
/// TRACES: FR-NC-6a
/// Download whatever the pins still want, reporting progress.
fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let Some(cache_dir) = ctl.cache_dir() else {
return;
};
// Offline, there is nothing to download from. The pin is already recorded,
// so it resumes on reconnect rather than being lost.
if ctl.is_offline() {
log::info!("offline: the pin is recorded and will download on reconnect");
return;
}
let rx = library::spawn_pin_fetch(
conn.clone(),
library::catalog_path(&conn.account),
cache_dir,
// Pinned originals are exempt from the budget, but a pin fetch also
// stores passively when it finds an image already cached, so the worker
// still needs the user's ceiling rather than the catalog's floor.
ctl.cache_budget.get(),
);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The longest-running transfer the app does, and the one most likely to be
// watched from another view — which is the whole reason the register
// exists (FR-NC-6, FR-NC-6c).
let job = ctl.activity.begin(
crate::activity::Kind::Download,
"Keeping photographs on this device",
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(300),
move || {
let Some(w) = weak.upgrade() else { return };
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.global::<Library>().set_library_pin_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.pin_timer);
return;
}
};
match msg {
library::PinMessage::Planned { total } => {
w.global::<Library>().set_library_pin_total(total as i32);
w.global::<Library>().set_library_pin_done(0);
job.total(total);
}
library::PinMessage::Stored { done } => {
w.global::<Library>().set_library_pin_done(done as i32);
job.progress(done, w.global::<Library>().get_library_pin_total() as usize);
// The "On this device" count grows as they land, so
// the chip agrees with the progress line beside it.
refresh_local_count(&w, &ctl_cb);
}
library::PinMessage::Done { stored, bytes } => {
log::info!(
"pin complete: {stored} original(s), {:.1} MB",
bytes as f64 / 1_048_576.0
);
job.finish(format!(
"{stored} photograph(s) · {}",
crate::activity::describe_bytes(bytes)
));
w.global::<Library>().set_library_pin_total(0);
w.global::<Library>().set_library_pin_done(0);
refresh_local_count(&w, &ctl_cb);
stop(&ctl_cb.pin_timer);
return;
}
library::PinMessage::Failed { message, offline } => {
log::warn!("pin fetch stopped: {message}");
job.fail(message.clone());
w.global::<Library>().set_library_pin_total(0);
if offline {
ctl_cb
.reachability
.borrow_mut()
.mark_unreachable(message, std::time::Instant::now());
refresh_offline(&w, &ctl_cb);
} else {
w.global::<Library>()
.set_library_error(format!("keeping offline: {message}").into());
}
refresh_local_count(&w, &ctl_cb);
stop(&ctl_cb.pin_timer);
return;
}
}
}
},
);
*ctl.pin_timer.borrow_mut() = Some(timer);
}
/// Refresh the "On this device" count from the catalog.
fn refresh_local_count(window: &AppWindow, ctl: &Rc<LibraryController>) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
window
.global::<Library>()
.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
}
/// TRACES: FR-NC-6a
/// Whether every image in the scoped collection is pinned.
///
/// Read from the catalog rather than remembered, because a pin outlives the
/// session that made it: reopening the library must show the button already
/// active, or the user would pin the same collection twice.
pub(super) fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
if images.is_empty() {
return false;
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let pinned: i64 = catalog
.connection()
.query_row(
&format!(
"SELECT count(*) FROM image_cache
WHERE pinned = 1 AND image_id IN ({placeholders})"
),
rusqlite::params_from_iter(params.iter()),
|r| r.get(0),
)
.unwrap_or(0);
pinned as usize == images.len()
}
/// TRACES: FR-CAT-9
/// Paint the connectivity state into the window.
///
/// Called wherever reachability may have moved, rather than by the state
/// itself: `Reachability` is in `dr-sync` and knows nothing about a window,
/// which is what keeps it testable without a display server.
pub(super) fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
let reach = ctl.reachability.borrow();
// TRACES: FR-PLAT-AND-2
// A lost root wins over a dead network, and does so even when both are
// true — which is the ordinary case, since the scan that discovered the
// grant was gone was also the last request the app made. Reported the
// other way round the user is told to wait for a connection that is
// working, and the thing that would actually fix it is never mentioned.
let lost = ctl.root_lost.borrow();
let offline = reach.is_offline() || lost.is_some();
window.global::<Library>().set_library_offline(offline);
window
.global::<Library>()
.set_library_offline_reason(match lost.as_deref() {
Some(why) => why.into(),
None => reach.reason().unwrap_or_default().into(),
});
window.global::<Library>().set_library_offline_since(
// A duration is what a network outage has and a revoked permission
// does not: "for 4 minutes" invites waiting, and waiting is precisely
// what will not help here.
if lost.is_some() {
slint::SharedString::default()
} else {
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into()
},
);
drop(lost);
// A stale scan error under an offline banner reports one problem twice.
if offline {
window.global::<Library>().set_library_error("".into());
}
drop(reach);
// TRACES: FR-CAT-9
// Back online: send whatever the outbox is still holding.
//
// Hung off the one function that paints connectivity rather than off each
// of the seven places that move it — a drain that had to be remembered at
// every call site is a drain that will be forgotten at one of them, and
// the symptom is an edit that stays queued until the app is restarted.
//
// `start_outbox_drain` is a no-op when the outbox is empty and while one
// is already running, so calling it on every repaint costs a directory
// walk that finds nothing.
if !offline {
start_outbox_drain(window, ctl);
}
}
/// TRACES: FR-CAT-9 | FR-NC-10
/// Upload the sidecars queued while this device had no connection.
///
/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is
/// running" state, and a second one started beside it would drain the same
/// channel twice.
fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
if ctl.outbox_timer.borrow().is_some() {
return;
}
if !ctl.outbox_maybe_dirty.get() {
return;
}
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
return;
};
if crate::sidecar_cache::SidecarCache::open(cache_dir.clone())
.pending()
.is_empty()
{
// Nothing there. Recorded so the next hundred repaints skip the walk;
// a write that queues sets it again.
ctl.outbox_maybe_dirty.set(false);
return;
}
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let rx = library::spawn_outbox_drain(conn.clone(), cache_dir);
let job = ctl
.activity
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(250),
move || {
let Some(w) = weak.upgrade() else { return };
match rx.try_recv() {
Ok(library::SidecarMessage::Finished {
written,
queued,
failed,
last_error,
}) => {
if failed > 0 {
log::warn!(
"{failed} queued sidecar(s) still undelivered: {}",
last_error.clone().unwrap_or_default()
);
// Not `fail`: the edits are still safely queued, and
// reporting this as a loss would be wrong.
job.finish(format!("{written} uploaded · {queued} still queued"));
} else if written > 0 {
log::info!("{written} queued sidecar(s) uploaded");
job.finish(format!("{written} queued edit(s) uploaded"));
w.global::<Library>().set_library_status(
format!("{written} queued edit(s) uploaded").into(),
);
} else {
job.finish_quietly();
}
ctl_cb.outbox_maybe_dirty.set(queued > 0);
stop(&ctl_cb.outbox_timer);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
job.finish_quietly();
stop(&ctl_cb.outbox_timer);
}
}
},
);
*ctl.outbox_timer.borrow_mut() = Some(timer);
}
/// A coarse "how long ago", for the offline banner.
///
/// Deliberately imprecise: the user wants to know whether this just happened
/// or has been true for a while, and a live-counting seconds display would
/// draw the eye to a number that changes without meaning anything.
fn describe_duration(d: std::time::Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else {
format!("{}h ago", secs / 3600)
}
}