Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
This commit is contained in:
+70
-88
@@ -17,7 +17,7 @@ use std::rc::Rc;
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use dr_catalog::Catalog;
|
||||
use dr_sync_nextcloud::{AppCredentials, Session, SessionStore};
|
||||
use dr_sync::{Account, AccountStore, Connection};
|
||||
use dr_types::FormatFilter;
|
||||
use slint::{ComponentHandle, Model as _};
|
||||
|
||||
@@ -177,7 +177,12 @@ pub struct LibraryController {
|
||||
/// Pushing shards and the catalog to the server.
|
||||
sync_timer: RefCell<Option<slint::Timer>>,
|
||||
/// Kept so a rescan can run without going back through the launch screen.
|
||||
session: RefCell<Option<(AppCredentials, Session, FormatFilter)>>,
|
||||
///
|
||||
/// A [`Connection`] rather than credentials beside an account: it is what
|
||||
/// every worker needs, it is what `remote::connect` takes, and holding the
|
||||
/// two halves separately is how they came to be threaded through fifteen
|
||||
/// signatures in the wrong order.
|
||||
session: RefCell<Option<(Connection, FormatFilter)>>,
|
||||
/// Which collection narrows the grid, owned by [`crate::collections_ui`]
|
||||
/// and read here. Shared rather than passed per call because a rescan, a
|
||||
/// scrub and a drop all reload the window and must all honour it.
|
||||
@@ -506,8 +511,8 @@ impl LibraryController {
|
||||
/// (FR-NC-6a), and a queued edit must never be.
|
||||
pub fn sidecar_cache_dir(&self) -> Option<PathBuf> {
|
||||
let borrow = self.session.borrow();
|
||||
let (_, session, _) = borrow.as_ref()?;
|
||||
library::catalog_path(&session.server, &session.user_id)
|
||||
let (conn, _) = borrow.as_ref()?;
|
||||
library::catalog_path(&conn.account)
|
||||
.parent()
|
||||
.map(|p| p.join("sidecars"))
|
||||
}
|
||||
@@ -520,8 +525,8 @@ impl LibraryController {
|
||||
/// catalog row is gone is unreachable anyway.
|
||||
pub fn cache_dir(&self) -> Option<PathBuf> {
|
||||
let borrow = self.session.borrow();
|
||||
let (_, session, _) = borrow.as_ref()?;
|
||||
library::catalog_path(&session.server, &session.user_id)
|
||||
let (conn, _) = borrow.as_ref()?;
|
||||
library::catalog_path(&conn.account)
|
||||
.parent()
|
||||
.map(|p| p.join("originals"))
|
||||
}
|
||||
@@ -565,8 +570,8 @@ impl LibraryController {
|
||||
/// three hundred would re-download every one of them.
|
||||
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
|
||||
let borrow = self.session.borrow();
|
||||
let (_, session, _) = borrow.as_ref()?;
|
||||
let catalog_path = library::catalog_path(&session.server, &session.user_id);
|
||||
let (conn, _) = borrow.as_ref()?;
|
||||
let catalog_path = library::catalog_path(&conn.account);
|
||||
let dir = catalog_path.parent()?.join("originals");
|
||||
drop(borrow);
|
||||
|
||||
@@ -602,15 +607,12 @@ impl LibraryController {
|
||||
self.catalog.clone()
|
||||
}
|
||||
|
||||
/// Credentials and session for the open library.
|
||||
/// The open library's connection.
|
||||
///
|
||||
/// Needed by the trash, whose `MOVE` and `DELETE` go to the same account the
|
||||
/// Needed by the trash, whose move and delete go to the same account the
|
||||
/// scan and thumbnail workers use. `None` before a library is opened.
|
||||
pub fn session(&self) -> Option<(AppCredentials, Session)> {
|
||||
self.session
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|(c, s, _)| (c.clone(), s.clone()))
|
||||
pub fn session(&self) -> Option<Connection> {
|
||||
self.session.borrow().as_ref().map(|(c, _)| c.clone())
|
||||
}
|
||||
|
||||
/// Catalog ids of the rows currently in the model, in model order.
|
||||
@@ -730,16 +732,12 @@ impl LibraryController {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Credentials and account for the open library, if one is open.
|
||||
/// The open library's connection, for a full-file fetch.
|
||||
///
|
||||
/// What a full-file fetch needs: the grid's paths are remote, so opening
|
||||
/// an image means downloading it, and that needs the same session the
|
||||
/// thumbnail workers use.
|
||||
pub fn credentials(&self) -> Option<(AppCredentials, String)> {
|
||||
self.session
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|(creds, session, _)| (creds.clone(), session.user_id.clone()))
|
||||
/// The grid's paths are remote, so opening an image means fetching it, and
|
||||
/// that goes through the same account the thumbnail workers use.
|
||||
pub fn credentials(&self) -> Option<Connection> {
|
||||
self.session.borrow().as_ref().map(|(c, _)| c.clone())
|
||||
}
|
||||
|
||||
/// Narrow the grid to a collection, or to the whole library with `None`.
|
||||
@@ -786,10 +784,13 @@ pub fn open(
|
||||
window: &AppWindow,
|
||||
ctl: Rc<LibraryController>,
|
||||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||||
store: &SessionStore,
|
||||
session: Session,
|
||||
store: &AccountStore,
|
||||
account: Account,
|
||||
) {
|
||||
let creds = match store.credentials(&session) {
|
||||
// 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.set_library_error(format!("credentials: {e}").into());
|
||||
@@ -798,8 +799,8 @@ pub fn open(
|
||||
}
|
||||
};
|
||||
|
||||
let filter = session.format_filter();
|
||||
*ctl.session.borrow_mut() = Some((creds.clone(), session.clone(), filter.clone()));
|
||||
let filter = account.format_filter();
|
||||
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
|
||||
|
||||
window.set_show_library(true);
|
||||
window.set_library_open(true);
|
||||
@@ -809,10 +810,10 @@ pub fn open(
|
||||
// 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.set_library_root_label(
|
||||
if session.root.is_empty() {
|
||||
format!("{} · whole account", session.user_id)
|
||||
if conn.account.root.is_empty() {
|
||||
format!("{} · whole account", conn.account.user_id)
|
||||
} else {
|
||||
format!("{}/{}", session.user_id, session.root)
|
||||
format!("{}/{}", conn.account.user_id, conn.account.root)
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
@@ -825,13 +826,13 @@ pub fn open(
|
||||
return;
|
||||
}
|
||||
|
||||
let path = library::catalog_path(&session.server, &session.user_id);
|
||||
let path = library::catalog_path(&conn.account);
|
||||
log::info!(
|
||||
"scanning {} for {} format(s) → {}",
|
||||
if session.root.is_empty() {
|
||||
if conn.account.root.is_empty() {
|
||||
"<account root>"
|
||||
} else {
|
||||
&session.root
|
||||
&conn.account.root
|
||||
},
|
||||
filter.iter().count(),
|
||||
path.display()
|
||||
@@ -842,9 +843,8 @@ pub fn open(
|
||||
show_catalog_now(window, &ctl, &path, &coll_ctl);
|
||||
|
||||
let rx = library::spawn_scan(
|
||||
creds,
|
||||
session.user_id.clone(),
|
||||
session.root.clone(),
|
||||
conn.clone(),
|
||||
conn.account.root.clone(),
|
||||
filter,
|
||||
path.clone(),
|
||||
);
|
||||
@@ -870,8 +870,8 @@ fn drain_scan(
|
||||
// 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((_, session, _)) if !session.root.is_empty() => {
|
||||
format!("Scanning {}", session.root)
|
||||
Some((c, _)) if !c.account.root.is_empty() => {
|
||||
format!("Scanning {}", c.account.root)
|
||||
}
|
||||
_ => "Scanning the library".to_string(),
|
||||
};
|
||||
@@ -1044,7 +1044,7 @@ fn start_rescan(
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
let Some((creds, session, filter)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, filter)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1052,11 +1052,10 @@ fn start_rescan(
|
||||
window.set_library_error(slint::SharedString::new());
|
||||
window.set_library_status("Rescanning…".into());
|
||||
|
||||
let path = library::catalog_path(&session.server, &session.user_id);
|
||||
let path = library::catalog_path(&conn.account);
|
||||
let rx = library::spawn_scan(
|
||||
creds,
|
||||
session.user_id.clone(),
|
||||
session.root.clone(),
|
||||
conn.clone(),
|
||||
conn.account.root.clone(),
|
||||
filter,
|
||||
path.clone(),
|
||||
);
|
||||
@@ -1413,7 +1412,7 @@ fn collection_images(catalog: &Catalog, ids: &[dr_types::CollectionId]) -> Vec<d
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Download whatever the pins still want, reporting progress.
|
||||
fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(cache_dir) = ctl.cache_dir() else {
|
||||
@@ -1428,9 +1427,8 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
}
|
||||
|
||||
let rx = library::spawn_pin_fetch(
|
||||
creds,
|
||||
session.user_id.clone(),
|
||||
library::catalog_path(&session.server, &session.user_id),
|
||||
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
|
||||
@@ -1629,11 +1627,11 @@ fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
ctl.outbox_maybe_dirty.set(false);
|
||||
return;
|
||||
}
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let rx = library::spawn_outbox_drain(creds, session.user_id.clone(), cache_dir);
|
||||
let rx = library::spawn_outbox_drain(conn.clone(), cache_dir);
|
||||
let job = ctl
|
||||
.activity
|
||||
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
|
||||
@@ -2764,7 +2762,7 @@ pub(crate) fn start_sidecar_writes(
|
||||
// write being conditional on it.
|
||||
let offline = ctl.is_offline();
|
||||
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
||||
@@ -2772,8 +2770,7 @@ pub(crate) fn start_sidecar_writes(
|
||||
};
|
||||
|
||||
let count = writes.len();
|
||||
let rx =
|
||||
library::spawn_sidecar_writes(creds, session.user_id.clone(), writes, cache_dir, offline);
|
||||
let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
@@ -2907,7 +2904,7 @@ fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usiz
|
||||
|
||||
/// Fetch thumbnails for rows in the model that do not have one yet.
|
||||
fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -2967,11 +2964,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
|
||||
let requested = wanted.len();
|
||||
let rx = library::spawn_thumbnails(
|
||||
creds,
|
||||
session.user_id.clone(),
|
||||
conn.clone(),
|
||||
wanted,
|
||||
library::thumbs_dir(&session.server, &session.user_id),
|
||||
library::catalog_path(&session.server, &session.user_id),
|
||||
library::thumbs_dir(&conn.account),
|
||||
library::catalog_path(&conn.account),
|
||||
);
|
||||
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class);
|
||||
}
|
||||
@@ -3022,14 +3018,11 @@ pub fn refresh_thumbnail(
|
||||
// nothing here to correct.
|
||||
return;
|
||||
};
|
||||
let Some((_, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(
|
||||
&session.server,
|
||||
&session.user_id,
|
||||
)) {
|
||||
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("re-thumbnailing {remote_path}: opening the store: {e}");
|
||||
@@ -3336,7 +3329,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
// Already running: a second pass would race the first over the same
|
||||
@@ -3355,7 +3348,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
return;
|
||||
}
|
||||
|
||||
let catalog_path = library::catalog_path(&session.server, &session.user_id);
|
||||
let catalog_path = library::catalog_path(&conn.account);
|
||||
let scratch = catalog_path
|
||||
.parent()
|
||||
.map(|p| p.join("scratch"))
|
||||
@@ -3373,14 +3366,9 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// for next time and nothing is lost by not watching it. It reports
|
||||
// through the log until an export has a place in the activity list.
|
||||
{
|
||||
let outbox = crate::export::outbox_dir(&session.server, &session.user_id);
|
||||
let outbox = crate::export::outbox_dir(&conn.account);
|
||||
if crate::export::pending_count(&outbox) > 0 {
|
||||
let rx = crate::export::spawn_upload(
|
||||
creds.clone(),
|
||||
session.user_id.clone(),
|
||||
session.root.clone(),
|
||||
outbox,
|
||||
);
|
||||
let rx = crate::export::spawn_upload(conn.clone(), conn.account.root.clone(), outbox);
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
match msg {
|
||||
@@ -3403,10 +3391,9 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
|
||||
window.set_library_syncing(true);
|
||||
let rx = crate::derived_sync::spawn_sync(
|
||||
creds,
|
||||
session.user_id.clone(),
|
||||
session.root.clone(),
|
||||
library::thumbs_dir(&session.server, &session.user_id),
|
||||
conn.clone(),
|
||||
conn.account.root.clone(),
|
||||
library::thumbs_dir(&conn.account),
|
||||
catalog_path,
|
||||
scratch,
|
||||
);
|
||||
@@ -3529,7 +3516,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
/// describes the fraction of the library that happened to be scrolled past.
|
||||
/// This covers the rest.
|
||||
fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -3544,11 +3531,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rx = library::spawn_sweep(
|
||||
creds,
|
||||
session.user_id.clone(),
|
||||
library::catalog_path(&session.server, &session.user_id),
|
||||
);
|
||||
let rx = library::spawn_sweep(conn.clone(), library::catalog_path(&conn.account));
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
@@ -3640,7 +3623,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
/// The sync at the end is not a separate courtesy: a filled store that never
|
||||
/// leaves this device is most of the cost for none of the point.
|
||||
fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -3664,10 +3647,9 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
|
||||
window.set_library_thumbnailing(true);
|
||||
let rx = library::spawn_thumbnail_sweep(
|
||||
creds,
|
||||
session.user_id.clone(),
|
||||
library::catalog_path(&session.server, &session.user_id),
|
||||
library::thumbs_dir(&session.server, &session.user_id),
|
||||
conn.clone(),
|
||||
library::catalog_path(&conn.account),
|
||||
library::thumbs_dir(&conn.account),
|
||||
);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
|
||||
Reference in New Issue
Block a user