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:
+92
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user