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:
2026-08-29 09:57:52 +02:00
parent 1b8b7998a2
commit f12aece07e
40 changed files with 3617 additions and 960 deletions
+92 -16
View File
@@ -8,8 +8,8 @@ use std::cell::RefCell;
use std::rc::Rc;
use dr_plat::PlatformSecretStore;
use dr_sync::RemotePath;
use dr_sync_nextcloud::{auth, Session, SessionStore};
use dr_sync::{Account, AccountStore, BackendProvider, RemotePath};
use dr_sync_nextcloud::{auth, NextcloudProvider};
use slint::ComponentHandle;
@@ -19,7 +19,7 @@ use crate::AppWindow;
/// Shared launch state for the running window.
pub struct LaunchController {
pub model: RefCell<LaunchModel>,
pub store: SessionStore,
pub store: AccountStore,
/// Holds any in-flight poll timer. A `Timer` stops when dropped, so it
/// must outlive its own callback — parking it here avoids an Rc cycle
/// between the timer and the closure it runs.
@@ -28,7 +28,7 @@ pub struct LaunchController {
impl LaunchController {
pub fn new() -> Rc<Self> {
let store = SessionStore::open(Box::new(PlatformSecretStore::new()));
let store = AccountStore::open(Box::new(PlatformSecretStore::new()));
let model = LaunchModel::from_store(&store);
Rc::new(Self {
model: RefCell::new(model),
@@ -46,6 +46,7 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
window.set_launch_account(m.account_label().into());
window.set_launch_root(m.library_root().into());
window.set_launch_server(m.server_url.clone().into());
window.set_launch_folder(m.folder_path.clone().into());
window.set_launch_busy(m.is_busy());
window.set_launch_login_url(m.login_url().into());
window.set_launch_can_remember(m.can_remember);
@@ -87,7 +88,7 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
/// the session so the caller can start a scan.
pub fn wire<F>(window: &AppWindow, controller: Rc<LaunchController>, on_open_library: F)
where
F: Fn(Session) + 'static,
F: Fn(Account) + 'static,
{
// --- sign in -------------------------------------------------------
{
@@ -96,7 +97,17 @@ where
window.on_launch_sign_in(move |server| {
log::info!("sign-in requested for {server:?}");
let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().begin_sign_in(server.to_string());
// The connector owns what a valid address is — assuming HTTPS
// here would put one backend's rule in the interface.
let server = match NextcloudProvider.normalise_endpoint(&server) {
Ok(s) => s,
Err(e) => {
ctl.model.borrow_mut().fail(e);
render(&w, &ctl);
return;
}
};
ctl.model.borrow_mut().begin_sign_in(server);
render(&w, &ctl);
let server = ctl.model.borrow().server_url.clone();
@@ -111,9 +122,15 @@ where
let ctl = controller.clone();
window.on_launch_sign_in_direct(move |server, login, password| {
let Some(w) = weak.upgrade() else { return };
ctl.model
.borrow_mut()
.begin_direct_sign_in(server.to_string());
let server = match NextcloudProvider.normalise_endpoint(&server) {
Ok(s) => s,
Err(e) => {
ctl.model.borrow_mut().fail(e);
render(&w, &ctl);
return;
}
};
ctl.model.borrow_mut().begin_direct_sign_in(server);
render(&w, &ctl);
let server = ctl.model.borrow().server_url.clone();
@@ -127,6 +144,62 @@ where
});
}
// --- use a folder ---------------------------------------------------
//
// No thread, no waiting state, no credential: the whole sign-in is a
// `stat`. That asymmetry with the browser flow above is not a special
// case in the screen — it is what [`SignIn::EndpointOnly`] means, and any
// future connector declaring it lands here rather than in new code.
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_use_folder(move |path| {
let Some(w) = weak.upgrade() else { return };
let provider = match crate::remote::registry().get(dr_sync_folder::BACKEND_ID) {
Some(p) => p.clone(),
None => {
ctl.model
.borrow_mut()
.fail("this build has no folder support");
render(&w, &ctl);
return;
}
};
// The connector checks the directory before an account is written
// for it. A typo stored here would skip the launch screen next
// start and surface as a library that finds nothing.
let endpoint = match provider.normalise_endpoint(&path) {
Ok(e) => e,
Err(e) => {
ctl.model.borrow_mut().fail(e);
render(&w, &ctl);
return;
}
};
let account = match provider.account_for(&endpoint) {
Ok(a) => a,
Err(e) => {
ctl.model.borrow_mut().fail(e.to_string());
render(&w, &ctl);
return;
}
};
// `None`: there is no credential, and asking the keyring for one
// would fail on a machine with no secrets daemon — where a folder
// library is exactly the thing that should still work.
if let Err(e) = ctl.store.save(&account, None) {
log::warn!("persisting account: {e}");
}
log::info!("using folder library at {}", account.endpoint);
ctl.model.borrow_mut().signed_in(account);
render(&w, &ctl);
});
}
// --- sign out ------------------------------------------------------
{
let weak = window.as_weak();
@@ -547,9 +620,10 @@ fn poll_channel(
}
LoginMessage::Success(boxed) => {
let (creds, user_id) = *boxed;
let session = Session::new(&creds, user_id);
if let Err(e) = ctl.store.save(&session, &creds) {
log::warn!("persisting session: {e}");
let session = NextcloudProvider::account_from(&creds, user_id);
let secret = dr_sync::Secret::new(&creds.app_password);
if let Err(e) = ctl.store.save(&session, Some(&secret)) {
log::warn!("persisting account: {e}");
}
ctl.model.borrow_mut().signed_in(session);
done = true;
@@ -576,10 +650,13 @@ fn poll_channel(
/// List top-level folders so one can be chosen as the library root.
fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, path: String) {
let Some(session) = ctl.model.borrow().session().cloned() else {
let Some(account) = ctl.model.borrow().session().cloned() else {
return;
};
let creds = match ctl.store.credentials(&session) {
let conn = match ctl
.store
.connection(&account, crate::remote::needs_secret(&account))
{
Ok(c) => c,
Err(e) => {
ctl.model.borrow_mut().fail(format!("credentials: {e}"));
@@ -591,7 +668,6 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
};
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
let user_id = session.user_id.clone();
std::thread::spawn(move || {
// Multi-thread for the same reason as the login worker: a
@@ -608,7 +684,7 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
return;
};
rt.block_on(async {
match crate::remote::connect(&creds, &user_id) {
match crate::remote::connect(&conn) {
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries