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
+1
View File
@@ -25,6 +25,7 @@ tokio.workspace = true
reqwest.workspace = true
dr-plat.workspace = true
dr-sync.workspace = true
dr-sync-folder.workspace = true
dr-sync-nextcloud.workspace = true
dr-export.workspace = true
dr-ingest.workspace = true
+16 -37
View File
@@ -1167,16 +1167,11 @@ fn start_trash(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some((creds, sess)) = session() else {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
@@ -1184,7 +1179,7 @@ fn start_trash(
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_trash(cat, &sess.root, images) {
match crate::trash::plan_trash(cat, &conn.account.root, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning delete: {e}").into());
@@ -1205,11 +1200,10 @@ fn start_trash(
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
conn.clone(),
moves,
crate::trash::Direction::ToTrash,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
);
drain_trash(
@@ -1239,16 +1233,11 @@ fn start_restore(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some((creds, sess)) = session() else {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
@@ -1282,11 +1271,10 @@ fn start_restore(
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
conn.clone(),
moves,
crate::trash::Direction::Restore,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
);
drain_trash(
@@ -1466,10 +1454,7 @@ pub fn wire<S, R, P, C>(
S: Fn() + 'static,
R: Fn() -> Vec<ImageId> + 'static,
P: Fn(usize, usize) -> Vec<ImageId> + 'static,
C: Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)> + 'static,
C: Fn() -> Option<dr_sync::Connection> + 'static,
{
// Coerced to trait objects here rather than at each use: `start_trash` and
// `drain_trash` are shared by three callbacks, and a generic parameter would
@@ -1479,12 +1464,7 @@ pub fn wire<S, R, P, C>(
// A shift-click asks the catalog what lies between its two ends, and the
// catalog belongs to the grid's controller — see `span_source`.
*ctl.span_source.borrow_mut() = Some(Rc::new(span_ids));
let session: Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
> = Rc::new(session);
let session: Rc<dyn Fn() -> Option<dr_sync::Connection>> = Rc::new(session);
// --- selection ---------------------------------------------------------
{
@@ -2098,8 +2078,8 @@ pub fn wire<S, R, P, C>(
window.on_trash_empty(move || {
let Some(w) = weak.upgrade() else { return };
let (creds, sess) = match session() {
Some(s) => s,
let conn = match session() {
Some(c) => c,
None => return,
};
@@ -2131,12 +2111,11 @@ pub fn wire<S, R, P, C>(
let count = ids.len();
let rx = crate::trash::spawn_purge(
creds,
sess.user_id.clone(),
conn.clone(),
ids,
paths,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::thumbs_dir(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
crate::library::thumbs_dir(&conn.account),
);
drain_trash(
+5 -6
View File
@@ -1,5 +1,5 @@
//! TRACES: FR-CAT-3 | FR-CAT-7 | FR-NC-7
//! Pushing derived state to Nextcloud: thumbnail shards and the catalog.
//! Pushing derived state to the library: thumbnail shards and the catalog.
//!
//! # What travels, and why only this
//!
@@ -34,8 +34,8 @@
use std::path::{Path, PathBuf};
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
use dr_thumbs::ThumbStore;
/// Folder under the library root holding derived state.
@@ -96,8 +96,7 @@ pub enum SyncMessage {
/// Runs on its own thread with its own runtime, like every other network path
/// here — the Slint loop must never block (NFR-P9).
pub fn spawn_sync(
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
thumbs_dir: PathBuf,
catalog_path: PathBuf,
@@ -117,7 +116,7 @@ pub fn spawn_sync(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(SyncMessage::Failed(e.to_string()));
+11 -13
View File
@@ -62,7 +62,7 @@ use std::time::Duration;
use dr_export::{Encoded, NameContext};
use dr_sync::RemotePath;
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection};
use dr_types::{ExportSettings, ExportTarget};
use crate::AppWindow;
@@ -72,8 +72,8 @@ use crate::AppWindow;
/// Beside the catalog, for the reason in the module docs. Per account,
/// because the destination folder is a path on one particular server and an
/// entry queued for one account is meaningless to another.
pub fn outbox_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
pub fn outbox_dir(account: &Account) -> PathBuf {
crate::library::catalog_path(account)
.parent()
.map(|p| p.join("outbox"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-outbox"))
@@ -146,7 +146,7 @@ impl Placed {
),
// Named as queued rather than exported: the file is real and
// finished, but it is not yet where the user asked for it, and
// saying "exported to Nextcloud" before it has uploaded would be
// saying "exported to the library" before it has uploaded would be
// a claim the app cannot keep if the disk is pulled.
Placed::Queued { remote_dir, .. } => {
let dir = if remote_dir.is_empty() {
@@ -318,8 +318,7 @@ pub enum UploadMessage {
/// network error would burn the whole queue against a server that is not
/// answering, and the next pass costs nothing.
pub fn spawn_upload(
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
outbox: PathBuf,
) -> std::sync::mpsc::Receiver<UploadMessage> {
@@ -339,7 +338,7 @@ pub fn spawn_upload(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(UploadMessage::Finished {
@@ -486,7 +485,7 @@ pub struct BatchRequest {
/// Credentials for the account the library is open on. `None` where no
/// library is open, which is fine for a [`Source::Rendered`] and fatal for
/// anything that has to be fetched.
pub creds: Option<(AppCredentials, String)>,
pub conn: Option<Connection>,
pub settings: ExportSettings,
pub outbox: PathBuf,
pub sidecar_cache: PathBuf,
@@ -692,7 +691,7 @@ fn render_from_library(
cache: Option<crate::library::CacheContext>,
cancel: &Cancel,
) -> Option<Result<RenderedItem, ItemError>> {
let Some((creds, user_id)) = request.creds.clone() else {
let Some(conn) = request.conn.clone() else {
return Some(Err(ItemError::Fetch("no library is open".into())));
};
let Some(gpu) = request.gpu.as_ref() else {
@@ -706,13 +705,12 @@ fn render_from_library(
// RAW, so it costs nothing to have in hand by the time there is a session
// to apply it to.
let sidecar_rx = crate::library::spawn_sidecar_fetch(
creds.clone(),
user_id.clone(),
conn.clone(),
path.to_string(),
request.sidecar_cache.clone(),
request.offline,
);
let bytes_rx = crate::library::spawn_full_fetch(creds, user_id, path.to_string(), cache);
let bytes_rx = crate::library::spawn_full_fetch(conn, path.to_string(), cache);
let bytes = match wait_for(&bytes_rx, cancel) {
Waited::Got(Ok(bytes)) => bytes,
@@ -1332,7 +1330,7 @@ mod tests {
fn request(settings: ExportSettings, sources: Vec<Source>) -> BatchRequest {
BatchRequest {
sources,
creds: None,
conn: None,
settings,
outbox: std::env::temp_dir().join("dr-batch-test-outbox"),
sidecar_cache: std::env::temp_dir().join("dr-batch-test-sidecars"),
+5 -11
View File
@@ -356,15 +356,10 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
/// boundary would not be.
/// What the whole-library face pass needs to reach the server.
///
/// Credentials and not just paths, because the pass fetches its own pixels: an
/// A connection and not just paths, because the pass fetches its own pixels: an
/// image with no proxy is the ordinary case, not one to skip (see
/// `library::spawn_face_sweep`).
pub type SweepPaths = (
dr_sync_nextcloud::AppCredentials,
String,
std::path::PathBuf,
std::path::PathBuf,
);
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
/// The detector and embedder files, when both are present.
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
@@ -690,7 +685,7 @@ pub fn wire<S, M, P>(
if ctl.regroup.borrow().is_some() {
return;
}
let Some((_, _, catalog_path, _)) = paths() else {
let Some((_, catalog_path, _)) = paths() else {
return;
};
@@ -782,7 +777,7 @@ pub fn wire<S, M, P>(
w.set_identity_model_missing(true);
return;
};
let Some((creds, user_id, catalog_path, store_dir)) = paths() else {
let Some((conn, catalog_path, store_dir)) = paths() else {
return;
};
@@ -791,8 +786,7 @@ pub fn wire<S, M, P>(
*ctl.activity.borrow_mut() =
Some(activity.begin(crate::activity::Kind::Index, "Indexing faces"));
*ctl.sweep.borrow_mut() = Some(crate::library::spawn_face_sweep(
creds,
user_id,
conn,
catalog_path,
store_dir,
detector,
+7 -8
View File
@@ -55,8 +55,8 @@ use std::sync::Arc;
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection, RemoteBackend, RemotePath};
use dr_types::{FormatFilter, RootId};
/// Which root the card is granted as, and which the library is.
@@ -103,8 +103,7 @@ pub struct Request {
/// an import, and the photographs exist on disk either way.
#[derive(Clone)]
pub struct Upload {
pub credentials: AppCredentials,
pub user_id: String,
pub conn: Connection,
/// The library folder on the server. The dated folders from the template
/// are created beneath it, the same ones the local copy went into.
pub library: String,
@@ -122,8 +121,8 @@ pub struct Upload {
/// TRACES: FR-NC-7b
/// Where an account's imports wait between disk and the server.
pub fn staging_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
pub fn staging_dir(account: &Account) -> PathBuf {
crate::library::catalog_path(account)
.parent()
.map(|p| p.join("staging"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-import-staging"))
@@ -133,7 +132,7 @@ impl std::fmt::Debug for Upload {
/// Hand-written so a credential cannot reach a log through a `{:?}`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Upload")
.field("user_id", &self.user_id)
.field("account", &self.conn.account.describe())
.field("library", &self.library)
.field("thumbs", &self.thumbs)
.field("staging", &self.staging)
@@ -385,7 +384,7 @@ fn upload_all(
};
rt.block_on(async {
let backend = match crate::remote::connect(&upload.credentials, &upload.user_id) {
let backend = match crate::remote::connect(&upload.conn) {
Ok(b) => b,
Err(e) => {
log::warn!("connecting to upload: {e}");
+80 -58
View File
@@ -3,8 +3,20 @@
//! The state machine lives here, separate from the Slint bindings, so it can
//! be tested without a display server. `dr-ui` owns presentation; what a
//! login *is* belongs to the connector.
//!
//! # Two shapes of sign-in, not two screens
//!
//! A Nextcloud account is established through a browser handshake the app
//! polls for; a folder library is established by naming a directory. The model
//! below does not know which is which — it asks the
//! [`BackendProvider`](dr_sync::BackendProvider) whether the account it is
//! being asked to make needs a credential
//! ([`SignIn`](dr_sync::SignIn)), and the screen draws the waiting state only
//! where there is something to wait for. Everything after that point — picking
//! a library root, ticking formats, opening the grid — is identical, because
//! it goes through `dr-sync` rather than through a connector.
use dr_sync_nextcloud::{Session, SessionStore};
use dr_sync::{Account, AccountStore};
use dr_types::{Format, FormatFilter};
/// What the launch screen is currently doing.
@@ -18,14 +30,14 @@ pub enum LaunchState {
/// handles the password itself (FR-NC-1).
AwaitingApproval { login_url: String },
/// An account is configured.
SignedIn { session: Session },
SignedIn { session: Account },
/// Working; the reason is shown so a pause is never unexplained.
///
/// Carries the session where there is one, so a failure mid-work returns
/// to the signed-in screen rather than signing the user out.
Busy {
message: String,
session: Option<Box<Session>>,
session: Option<Box<Account>>,
},
}
@@ -53,6 +65,12 @@ pub struct LaunchModel {
pub state: LaunchState,
/// Last-used server, prefilled so a returning user need not retype it.
pub server_url: String,
/// Last-used folder, prefilled for the same reason.
///
/// Separate from `server_url` rather than one "endpoint" field, because
/// the screen shows both at once: someone deciding between the two should
/// not have to clear one to try the other.
pub folder_path: String,
pub error: Option<String>,
pub status: Option<String>,
/// False where no secrets daemon exists (FR-NC-2). The screen must say so
@@ -132,6 +150,7 @@ impl Default for LaunchModel {
Self {
state: LaunchState::SignedOut,
server_url: String::new(),
folder_path: String::new(),
error: None,
status: None,
can_remember: true,
@@ -143,14 +162,16 @@ impl Default for LaunchModel {
impl LaunchModel {
/// Build from stored sessions, resuming the last account if there is one.
pub fn from_store(store: &SessionStore) -> Self {
pub fn from_store(store: &AccountStore) -> Self {
let can_remember = store.can_remember();
match store.current() {
Some(session) => {
let filter = session.format_filter();
let (server_url, folder_path) = prefill(&session);
Self {
server_url: session.server.clone(),
server_url,
folder_path,
formats: Format::ALL
.iter()
.map(|f| (*f, filter.allows(*f)))
@@ -175,7 +196,7 @@ impl LaunchModel {
matches!(self.state, LaunchState::Busy { .. })
}
pub fn session(&self) -> Option<&Session> {
pub fn session(&self) -> Option<&Account> {
match &self.state {
LaunchState::SignedIn { session } => Some(session),
// A session survives a busy period; a scan failure must not log
@@ -245,7 +266,7 @@ impl LaunchModel {
// --- transitions ---------------------------------------------------
pub fn begin_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.server_url = server.into();
self.error = None;
self.state = LaunchState::Busy {
message: "Contacting server…".into(),
@@ -263,7 +284,7 @@ impl LaunchModel {
/// Security. That makes it the workable option where no browser can
/// complete the handshake.
pub fn begin_direct_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.server_url = server.into();
self.error = None;
self.state = LaunchState::Busy {
message: "Checking the credentials…".into(),
@@ -278,10 +299,14 @@ impl LaunchModel {
};
}
pub fn signed_in(&mut self, session: Session) {
pub fn signed_in(&mut self, session: Account) {
self.error = None;
self.status = None;
self.server_url = session.server.clone();
let (server_url, folder_path) = prefill(&session);
self.server_url = server_url;
if !folder_path.is_empty() {
self.folder_path = folder_path;
}
let filter = session.format_filter();
// Adopt the session's stored selection, so a returning user sees the
// tick-boxes they left.
@@ -357,7 +382,7 @@ impl LaunchModel {
/// Adopt the picker's current path as the library root.
///
/// Returns the session to persist, or `None` when signed out.
pub fn choose_current_folder(&mut self) -> Option<Session> {
pub fn choose_current_folder(&mut self) -> Option<Account> {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
@@ -378,25 +403,16 @@ impl LaunchModel {
}
}
/// Normalise a server address typed by hand.
/// Which of the two entry fields an account's endpoint belongs in.
///
/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than
/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS,
/// and an unencrypted default would be a security decision made on the user's
/// behalf without telling them.
pub fn normalise_server(input: &str) -> String {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return String::new();
}
if s.starts_with("https://") {
s.to_string()
} else if let Some(rest) = s.strip_prefix("http://") {
// Upgrade rather than accept. If the server genuinely has no TLS the
// connection fails loudly, which is the correct outcome.
format!("https://{rest}")
/// A returning user should find what they typed last time where they typed
/// it. Keyed on whether the connector has a login rather than on its id, so a
/// third backend does not have to be named here to be prefilled correctly.
fn prefill(account: &Account) -> (String, String) {
if account.login.is_empty() {
(String::new(), account.endpoint.clone())
} else {
format!("https://{s}")
(account.endpoint.clone(), String::new())
}
}
@@ -404,18 +420,17 @@ pub fn normalise_server(input: &str) -> String {
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
use dr_sync_nextcloud::AppCredentials;
use dr_sync::Secret;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example".into(),
login_name: "duncan".into(),
app_password: "token".into(),
}
fn session_with_root(root: &str) -> Account {
let mut s =
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
s.root = root.into();
s
}
fn session_with_root(root: &str) -> Session {
let mut s = Session::new(&creds(), "duncan");
fn folder_with_root(root: &str) -> Account {
let mut s = Account::new("folder", "/mnt/photos");
s.root = root.into();
s
}
@@ -513,27 +528,34 @@ mod tests {
}
#[test]
fn server_addresses_are_normalised_to_https() {
assert_eq!(normalise_server("cloud.example"), "https://cloud.example");
assert_eq!(
normalise_server("https://cloud.example/"),
"https://cloud.example"
);
assert_eq!(
normalise_server(" cloud.example "),
"https://cloud.example"
);
assert_eq!(normalise_server(""), "");
fn a_returning_user_finds_their_endpoint_where_they_typed_it() {
// Two entry fields are shown at once. Prefilling the wrong one — a
// folder path into the server box — reads as a corrupted setting.
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
assert_eq!(m.server_url, "https://cloud.example");
assert_eq!(m.folder_path, "");
let mut m = LaunchModel::default();
m.signed_in(folder_with_root("2026"));
assert_eq!(m.folder_path, "/mnt/photos");
assert_eq!(m.server_url, "");
}
#[test]
fn plain_http_is_upgraded_rather_than_accepted() {
// NFR-SEC-3: TLS is required. Failing loudly beats silently sending a
// credential in the clear.
assert_eq!(
normalise_server("http://cloud.example"),
"https://cloud.example"
);
fn a_folder_library_reaches_the_grid_the_same_way_a_server_one_does() {
// Everything past sign-in is backend-neutral, and this is the check
// that keeps it so: no branch on the account's connector below here.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.is_signed_in());
assert!(!m.can_open_library(), "no root chosen yet");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
m.signed_in(folder_with_root("2026"));
assert!(m.can_open_library());
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
assert_eq!(m.account_label(), "/mnt/photos/2026");
}
#[test]
@@ -554,13 +576,13 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
let store = AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
let mut s = session_with_root("PhotosRaw");
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2]));
store.save(&s, &creds()).unwrap();
store.save(&s, Some(&Secret::new("token"))).unwrap();
let m = LaunchModel::from_store(&store);
assert!(m.is_signed_in());
@@ -576,7 +598,7 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
let store = AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
+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
+37 -49
View File
@@ -406,10 +406,10 @@ fn batch_request(
export::BatchRequest {
sources,
creds: library.credentials(),
conn: library.credentials(),
settings: stored.export,
outbox: match library.session() {
Some((_, s)) => export::outbox_dir(&s.server, &s.user_id),
Some(c) => export::outbox_dir(&c.account),
// No account, so no outbox — a device export still works, and a
// remote one is refused by `place` rather than here, so the message
// names the setting rather than the plumbing.
@@ -434,15 +434,16 @@ fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
if library.is_offline() {
return;
}
let Some((creds, session)) = library.session() else {
let Some(conn) = library.session() else {
return;
};
let outbox = export::outbox_dir(&session.server, &session.user_id);
let outbox = export::outbox_dir(&conn.account);
if export::pending_count(&outbox) == 0 {
return;
}
let rx = export::spawn_upload(creds, session.user_id.clone(), session.root.clone(), outbox);
let root = conn.account.root.clone();
let rx = export::spawn_upload(conn, root, outbox);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
@@ -472,7 +473,9 @@ fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsC
let remote = stored.export.target == dr_types::ExportTarget::Remote;
window.set_export_label(
if remote {
"Export to Nextcloud"
// Not the connector's name: this is a Nextcloud account for some
// libraries and a folder on a mount for others.
"Export to the library"
} else {
"Export"
}
@@ -1036,13 +1039,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
library.catalog(),
activity.clone(),
move || {
let (_, session) = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(
&session.server,
&session.user_id,
))
.ok()
.map(std::rc::Rc::new)
let conn = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account))
.ok()
.map(std::rc::Rc::new)
},
// The weights are not shipped and are not a build input
// (docs/faces.md §2): the user puts them beside the catalog,
@@ -1050,24 +1050,21 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
{
let lib = library.clone();
move || {
let (_, session) = lib.session()?;
library::face_models(&session.server, &session.user_id)
let conn = lib.session()?;
library::face_models(&conn.account)
}
},
// The sweep opens its own connection on its own thread, so it
// takes paths rather than the handles this screen holds — and
// credentials, because it fetches the pixels it indexes rather
// a connection, because it fetches the pixels it indexes rather
// than reading whatever the grid happened to leave behind.
{
let lib = library.clone();
move || {
let (creds, session) = lib.session()?;
Some((
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
library::thumbs_dir(&session.server, &session.user_id),
))
let conn = lib.session()?;
let catalog = library::catalog_path(&conn.account);
let thumbs = library::thumbs_dir(&conn.account);
Some((conn, catalog, thumbs))
}
},
);
@@ -1100,7 +1097,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Before anything opens a store: an upgrade must not
// abandon a catalog, its thumbnails, or the offline
// ratings and edits waiting beside them.
library::migrate_legacy_cache_data(&session.server, &session.user_id);
library::migrate_legacy_cache_data(&session);
library_ui::open(
&window,
library.clone(),
@@ -1147,23 +1144,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
&window,
import,
move || {
let (creds, session) = library_for_context.session()?;
let conn = library_for_context.session()?;
Some(import_ui::Context {
catalog: library::catalog_path(&session.server, &session.user_id),
library_label: session.root.clone(),
catalog: library::catalog_path(&conn.account),
library_label: conn.account.root.clone(),
// The same formats the scan looks for. An import that took
// types the library then ignores would copy files off the
// card that never appear in the grid.
filter: session.format_filter(),
filter: conn.account.format_filter(),
upload: Some(import::Upload {
credentials: creds,
user_id: session.user_id.clone(),
library: session.root.clone(),
library: conn.account.root.clone(),
// The same shard store the grid reads and the sync
// pushes, so a thumbnail made during an import is the
// one every other client gets.
thumbs: library::thumbs_dir(&session.server, &session.user_id),
staging: import::staging_dir(&session.server, &session.user_id),
thumbs: library::thumbs_dir(&conn.account),
staging: import::staging_dir(&conn.account),
conn,
}),
})
},
@@ -1218,14 +1214,8 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
library: &Rc<library_ui::LibraryController>,
path: String| {
match library.session() {
Some((creds, session)) => {
settings_ui::spawn_folder_list(
weak.clone(),
ctl.clone(),
creds,
session.user_id.clone(),
path,
);
Some(conn) => {
settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, path);
}
None => {
// No account, so nothing to browse. Said plainly rather
@@ -1364,14 +1354,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let lib = library.clone();
let catalog = library.catalog();
move |w: &AppWindow| {
let store = lib.session().and_then(|(_, s)| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&s.server, &s.user_id))
.ok()
let store = lib.session().and_then(|c| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok()
});
identity_ui::refresh_coverage(w, &catalog, store.as_ref());
w.set_identity_model_missing(
lib.session()
.and_then(|(_, s)| library::face_models(&s.server, &s.user_id))
.and_then(|c| library::face_models(&c.account))
.is_none(),
);
}
@@ -1780,7 +1769,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
w.set_index(0);
w.set_total(1);
let Some((creds, user_id)) = library.credentials() else {
let Some(conn) = library.credentials() else {
w.set_load_error("no library session".into());
return;
};
@@ -1809,8 +1798,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// photograph is, instead of the image appearing at its defaults
// and visibly changing a moment later.
let sidecar_rx = library::spawn_sidecar_fetch(
creds.clone(),
user_id.clone(),
conn.clone(),
path.clone(),
library.sidecar_cache_dir().unwrap_or_default(),
library.is_offline(),
@@ -1829,7 +1817,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
log::info!("fetching {path} for develop");
w.set_load_error("Downloading…".into());
let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache);
let rx = library::spawn_full_fetch(conn, path.clone(), cache);
// The one transfer the user is actively waiting on. It gets a row
// like any other, so a download that is still running after they
+72 -77
View File
@@ -25,8 +25,7 @@ use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use dr_catalog::{Catalog, JobKind, Priority};
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection, RemoteBackend, RemoteId, RemotePath};
use dr_thumbs::ThumbStore;
use crate::sidecar_cache::SidecarCache;
@@ -573,8 +572,7 @@ pub fn sidecar_path(image_path: &str) -> String {
/// about it. Interrupting a cull with an error dialog per frame would be far
/// worse than the risk. The counts are reported once, at the end.
pub fn spawn_sidecar_writes(
creds: AppCredentials,
user_id: String,
conn: Connection,
writes: Vec<SidecarWrite>,
cache_dir: PathBuf,
offline: bool,
@@ -626,7 +624,7 @@ pub fn spawn_sidecar_writes(
let report = match rt {
None => queue_all(),
Some(rt) => rt.block_on(async {
match crate::remote::connect(&creds, &user_id) {
match crate::remote::connect(&conn) {
Ok(b) => {
let mut report = SidecarReport::default();
for w in &writes {
@@ -860,11 +858,7 @@ async fn write_one_sidecar_online(
/// The marker is cleared only after the server has taken the bytes. A drain
/// interrupted halfway leaves the rest of the outbox exactly as it was, so
/// nothing depends on this running to completion.
pub fn spawn_outbox_drain(
creds: AppCredentials,
user_id: String,
cache_dir: PathBuf,
) -> Receiver<SidecarMessage> {
pub fn spawn_outbox_drain(conn: Connection, cache_dir: PathBuf) -> Receiver<SidecarMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
@@ -895,7 +889,7 @@ pub fn spawn_outbox_drain(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(SidecarMessage::Finished {
@@ -1001,20 +995,17 @@ fn merge_into(local: &mut dr_pipeline::Sidecar, remote: &dr_pipeline::Sidecar) {
/// Where the catalog for an account lives.
///
/// Keyed by server and user so two accounts do not share an index. Under the
/// XDG data directory, not cache: the catalog is rebuildable but rebuilding it
/// costs a full rescan, so it is not something to discard on a cache sweep.
pub fn catalog_path(server: &str, user_id: &str) -> PathBuf {
let slug: String = server
.trim_start_matches("https://")
.trim_start_matches("http://")
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
data_root()
.join(format!("{slug}-{user_id}"))
.join("catalog.sqlite")
/// Keyed by [`Account::namespace`] so two accounts do not share an index —
/// two servers, two logins on one server, or two folders on one disk. Under
/// the XDG data directory, not cache: the catalog is rebuildable but
/// rebuilding it costs a full rescan, so it is not something to discard on a
/// cache sweep.
///
/// The namespace is the account's to compute, not this function's, because it
/// is also frozen: it names the directory an existing install's catalog,
/// thumbnail shards and un-uploaded sidecars are already in.
pub fn catalog_path(account: &Account) -> PathBuf {
data_root().join(account.namespace()).join("catalog.sqlite")
}
/// The directory every account's data hangs off.
@@ -1032,7 +1023,7 @@ pub fn catalog_path(server: &str, user_id: &str) -> PathBuf {
/// reached the server, is the worst failure this application can have, and
/// it would be silent.
///
/// `SessionStore::data_dir()` is the persistent per-app directory the
/// `AccountStore::data_dir()` is the persistent per-app directory the
/// Android entry point establishes before anything opens a store. On a
/// desktop it is the XDG config directory, and the two lines below keep the
/// established XDG *data* location there rather than moving anyone's
@@ -1041,7 +1032,7 @@ fn data_root() -> PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
.unwrap_or_else(dr_sync_nextcloud::session::SessionStore::data_dir);
.unwrap_or_else(dr_sync::AccountStore::data_dir);
base.join("darkroom")
}
@@ -1064,15 +1055,12 @@ fn data_root() -> PathBuf {
/// one filesystem, so it is atomic and cannot half-finish. If the destination
/// already exists this does nothing — the migration has run, or this is a
/// fresh install, and in neither case may it overwrite live data.
pub fn migrate_legacy_cache_data(server: &str, user_id: &str) {
pub fn migrate_legacy_cache_data(account: &Account) {
// Only meaningful where the old fallback and the new one differ, which is
// exactly the platform that had the problem. On a desktop with XDG set,
// both resolve to the same place and this returns immediately.
let legacy_base = std::env::temp_dir();
let Some(current) = catalog_path(server, user_id)
.parent()
.map(|p| p.to_path_buf())
else {
let Some(current) = catalog_path(account).parent().map(|p| p.to_path_buf()) else {
return;
};
let Some(account) = current.file_name() else {
@@ -1117,8 +1105,7 @@ fn move_account_dir(legacy: &std::path::Path, current: &std::path::Path) {
/// Returns the receiver the UI drains. The worker owns its own tokio runtime
/// and backend; nothing here touches the Slint event loop.
pub fn spawn_scan(
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
filter: FormatFilter,
catalog_path: PathBuf,
@@ -1127,7 +1114,7 @@ pub fn spawn_scan(
std::thread::spawn(move || {
let started = std::time::Instant::now();
if let Err(e) = run_scan(&tx, creds, user_id, root, filter, catalog_path, started) {
if let Err(e) = run_scan(&tx, conn, root, filter, catalog_path, started) {
let _ = tx.send(ScanMessage::Failed {
message: e.message,
offline: e.offline,
@@ -1169,8 +1156,7 @@ impl From<dr_sync::RemoteError> for ScanFailure {
fn run_scan(
tx: &Sender<ScanMessage>,
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
filter: FormatFilter,
catalog_path: PathBuf,
@@ -1185,7 +1171,7 @@ fn run_scan(
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
rt.block_on(async {
let backend = crate::remote::connect(&creds, &user_id).map_err(ScanFailure::local)?;
let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?;
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
// first run this is empty and the walk is complete; on every run after
@@ -1469,8 +1455,7 @@ pub enum PinMessage {
/// memory — and it is the same contention that produced 423 Locked in the
/// sweep.
pub fn spawn_pin_fetch(
creds: AppCredentials,
user_id: String,
conn: Connection,
catalog_path: PathBuf,
cache_dir: PathBuf,
budget: dr_catalog::Budget,
@@ -1531,7 +1516,7 @@ pub fn spawn_pin_fetch(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(PinMessage::Failed {
@@ -1678,8 +1663,7 @@ pub struct CacheContext {
/// not read, so an edit this build failed to understand is never destroyed by
/// having been opened.
pub fn spawn_sidecar_fetch(
creds: AppCredentials,
user_id: String,
conn: Connection,
image_path: String,
cache_dir: PathBuf,
offline: bool,
@@ -1720,7 +1704,7 @@ pub fn spawn_sidecar_fetch(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
log::debug!("sidecar fetch backend: {e}");
@@ -1769,8 +1753,7 @@ pub fn spawn_sidecar_fetch(
}
pub fn spawn_full_fetch(
creds: AppCredentials,
user_id: String,
conn: Connection,
path: String,
cache: Option<CacheContext>,
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
@@ -1808,7 +1791,7 @@ pub fn spawn_full_fetch(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(Err(FetchFailure::local(e)));
@@ -1851,8 +1834,7 @@ pub fn spawn_full_fetch(
/// or a second device that synced the shards — fills the grid with no transfer
/// at all. Only genuine misses reach the network.
pub fn spawn_thumbnails(
creds: AppCredentials,
user_id: String,
conn: Connection,
wanted: Vec<ThumbnailRequest>,
store_dir: PathBuf,
catalog_path: PathBuf,
@@ -1958,7 +1940,7 @@ pub fn spawn_thumbnails(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
for req in &to_fetch {
@@ -2487,11 +2469,7 @@ const SWEEP_LANES: usize = 6;
///
/// Runs at the back of the queue by design: it holds no lock the grid needs,
/// and its chunked commits keep write transactions short.
pub fn spawn_sweep(
creds: AppCredentials,
user_id: String,
catalog_path: PathBuf,
) -> Receiver<SweepMessage> {
pub fn spawn_sweep(conn: Connection, catalog_path: PathBuf) -> Receiver<SweepMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
@@ -2529,7 +2507,7 @@ pub fn spawn_sweep(
};
rt.block_on(async {
let Ok(backend) = crate::remote::connect(&creds, &user_id) else {
let Ok(backend) = crate::remote::connect(&conn) else {
return;
};
@@ -2862,8 +2840,7 @@ fn faces_without_proxy(
/// the images in flight and nothing else.
#[allow(clippy::too_many_arguments)]
pub fn spawn_face_sweep(
creds: AppCredentials,
user_id: String,
conn: Connection,
catalog_path: PathBuf,
store_dir: PathBuf,
detector_model: PathBuf,
@@ -2991,7 +2968,7 @@ pub fn spawn_face_sweep(
rt.block_on(async {
// Through `remote::connect`, which is the only place in the
// interface that knows whose backend this is.
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
log::warn!("face sweep: {e}");
@@ -3226,8 +3203,7 @@ pub enum ThumbSweepMessage {
/// the alternative is a second piece of state that has to be invalidated when
/// a file is replaced.
pub fn spawn_thumbnail_sweep(
creds: AppCredentials,
user_id: String,
conn: Connection,
catalog_path: PathBuf,
store_dir: PathBuf,
) -> Receiver<ThumbSweepMessage> {
@@ -3300,7 +3276,7 @@ pub fn spawn_thumbnail_sweep(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
log::warn!("thumbnail sweep: {e}");
@@ -3457,8 +3433,8 @@ fn thumbnails_outstanding(
/// Beside the catalog rather than in the cache directory: these sync to the
/// server and are shared with other clients, so discarding them on a cache
/// sweep would cost a re-download for everyone.
pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf {
catalog_path(server, user_id)
pub fn thumbs_dir(account: &Account) -> PathBuf {
catalog_path(account)
.parent()
.map(|p| p.join("thumbs"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-thumbs"))
@@ -3471,8 +3447,8 @@ pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf {
/// project's licence, so the user obtains them and the app loads them from here
/// (docs/faces.md §2). An absent directory is the ordinary state of a fresh
/// install, not an error.
pub fn face_models_dir(server: &str, user_id: &str) -> PathBuf {
catalog_path(server, user_id)
pub fn face_models_dir(account: &Account) -> PathBuf {
catalog_path(account)
.parent()
.map(|p| p.join("models"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-models"))
@@ -3511,13 +3487,13 @@ pub fn shared_face_models_dir() -> PathBuf {
/// 3. **The system directories.** Where a package installs them — the Arch
/// package puts the pair in `/usr/share/darkroom/models`. Last, so anything
/// the user placed themselves outranks what the package shipped.
pub fn face_models(server: &str, user_id: &str) -> Option<(PathBuf, PathBuf)> {
pub fn face_models(account: &Account) -> Option<(PathBuf, PathBuf)> {
fn pair(dir: PathBuf) -> Option<(PathBuf, PathBuf)> {
let detector = dir.join("scrfd_500m_640.onnx");
let embedder = dir.join("arcface_mbf_b1.onnx");
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
}
pair(face_models_dir(server, user_id))
pair(face_models_dir(account))
.or_else(|| pair(shared_face_models_dir()))
.or_else(|| system_face_models_dirs().into_iter().find_map(pair))
}
@@ -4302,17 +4278,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// An account for the path tests, defaulting to the connector every
/// existing install uses.
fn account(endpoint: &str, user: &str) -> Account {
Account::new("nextcloud", endpoint).with_login(user, user)
}
#[test]
fn catalog_paths_separate_accounts() {
// Two accounts on one machine must not share an index, or one
// library's images appear in the other.
let a = catalog_path("https://cloud.example", "duncan");
let b = catalog_path("https://cloud.example", "someone");
let c = catalog_path("https://other.example", "duncan");
let a = catalog_path(&account("https://cloud.example", "duncan"));
let b = catalog_path(&account("https://cloud.example", "someone"));
let c = catalog_path(&account("https://other.example", "duncan"));
assert_ne!(a, b);
assert_ne!(a, c);
}
#[test]
fn a_folder_library_gets_its_own_catalog() {
// The same rule across backends: a folder library on this machine
// must not land in the directory a server account is already using.
let server = catalog_path(&account("https://cloud.example", "duncan"));
let folder = catalog_path(&Account::new("folder", "/mnt/photos"));
assert_ne!(server, folder);
assert_ne!(
folder,
catalog_path(&Account::new("folder", "/mnt/other-photos"))
);
}
#[test]
fn a_legacy_cache_directory_is_moved_rather_than_abandoned() {
// The upgrade hazard: `sidecars/` and `outbox/` hold work that exists
@@ -4372,7 +4367,7 @@ mod tests {
// and edit, and `outbox/`, holding exports the user was told had
// succeeded. Losing a day of culling to an OS housekeeping pass, with
// no error and no trace, is the worst outcome this application has.
let path = catalog_path("https://cloud.example", "duncan");
let path = catalog_path(&account("https://cloud.example", "duncan"));
let text = path.to_string_lossy().to_lowercase();
assert!(
!text.contains("/cache/") && !text.contains("/tmp/"),
@@ -4386,17 +4381,17 @@ mod tests {
// Stated as a test because three separate call sites derive their
// location by taking this path's parent, and a change here moves all
// of them at once — including the two holding unsynced user work.
let catalog = catalog_path("https://cloud.example", "duncan");
let catalog = catalog_path(&account("https://cloud.example", "duncan"));
let parent = catalog.parent().expect("a parent");
assert_eq!(
crate::export::outbox_dir("https://cloud.example", "duncan"),
crate::export::outbox_dir(&account("https://cloud.example", "duncan")),
parent.join("outbox")
);
}
#[test]
fn catalog_path_is_filesystem_safe() {
let p = catalog_path("https://cloud.example.com:8443/nc", "duncan");
let p = catalog_path(&account("https://cloud.example.com:8443/nc", "duncan"));
let s = p.to_string_lossy();
assert!(!s.contains("://"));
assert!(!s.contains(':') || cfg!(windows));
@@ -5029,8 +5024,8 @@ mod tests {
fn thumbs_live_beside_the_catalog_not_in_the_cache() {
// They sync to the server and are shared with other clients, so a
// cache sweep must not discard them.
let cat = catalog_path("https://cloud.example", "duncan");
let thumbs = thumbs_dir("https://cloud.example", "duncan");
let cat = catalog_path(&account("https://cloud.example", "duncan"));
let thumbs = thumbs_dir(&account("https://cloud.example", "duncan"));
assert_eq!(thumbs.parent(), cat.parent());
}
+70 -88
View File
@@ -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();
+81 -22
View File
@@ -1,4 +1,4 @@
// TRACES: FR-NC-12
// TRACES: FR-NC-12 | FR-NC-13
//! The one place the interface names a backend.
//!
//! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts
@@ -9,32 +9,91 @@
//! bought nothing it was designed for and a WebDAV or local-folder backend
//! would have had nowhere to go.
//!
//! Everything above this module now works through `&dyn RemoteBackend`. Adding
//! a backend is implementing the trait and changing [`connect`] — not editing
//! seven files.
//! Everything above this module works through `&dyn RemoteBackend`, and every
//! account it opens is a [`dr_sync::Account`] — configuration with no server
//! in it. Adding a backend is implementing two traits and adding a line to
//! [`registry`]; nothing else in `dr-ui` changes. See `docs/storage.md`.
//!
//! ## What is deliberately still Nextcloud-shaped
//! # Why the registry is built here and not in `dr-sync`
//!
//! Credentials. [`AppCredentials`] is an app password obtained through Login
//! Flow v2, which is a Nextcloud protocol rather than a general notion of
//! "how one authenticates to a remote". Abstracting it needs a decision about
//! what an account *is* across backends — an OAuth token, a bucket key pair
//! and an app password have no useful common shape — and inventing one before
//! a second backend exists would produce a wrong answer confidently. That is
//! the remaining half of this seam, and it is a design problem rather than a
//! mechanical one.
//! `dr-sync` must not depend on any connector, or the engine would drag a TLS
//! stack into a build that only wanted a folder. So the crate that already
//! depends on all of them — the interface — is where the list lives. It is
//! the only file in the application that names one.
use dr_sync::{RemoteBackend, RemoteError};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
use std::sync::{Arc, OnceLock};
/// Open a connection to the configured remote.
use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError};
use dr_sync_folder::FolderProvider;
use dr_sync_nextcloud::NextcloudProvider;
/// Every storage backend this build has, in the order the launch screen
/// offers them.
///
/// Built once. A provider is stateless — it holds no connection and no
/// credential — so one instance serves every thread that asks.
pub fn registry() -> &'static BackendRegistry {
static REGISTRY: OnceLock<BackendRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut r = BackendRegistry::new();
r.register(Arc::new(NextcloudProvider));
r.register(Arc::new(FolderProvider));
r
})
}
/// The connector serving an account.
///
/// Errors when this build has none — a configuration file outlives the binary
/// that wrote it, and saying *which* backend is missing beats "could not open
/// library".
pub fn provider_for(account: &Account) -> Result<&'static Arc<dyn BackendProvider>, RemoteError> {
registry().for_account(account)
}
/// Whether an account's credential has to be fetched from secure storage.
///
/// Asked of the connector rather than inferred from the account, because an
/// empty login might be a folder library or might be a damaged record, and
/// guessing turns the second into a silent unauthenticated connection.
pub fn needs_secret(account: &Account) -> bool {
provider_for(account).is_ok_and(|p| p.sign_in().needs_secret())
}
/// Open a connection to a configured remote.
///
/// Returns the trait object every caller should hold. The error type is
/// `dr-sync`'s rather than the connector's, so a caller handles a failure
/// `dr-sync`'s rather than a connector's, so a caller handles a failure
/// without learning which backend produced it.
pub(crate) fn connect(
creds: &AppCredentials,
user_id: &str,
) -> Result<Box<dyn RemoteBackend>, RemoteError> {
Ok(Box::new(NextcloudBackend::new(creds, user_id)?))
pub(crate) fn connect(conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
registry().connect(conn)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_backends_are_registered() {
// The list the launch screen offers. A backend missing from here is a
// backend the user cannot choose, however complete its connector is.
let ids: Vec<&str> = registry().providers().iter().map(|p| p.id()).collect();
assert!(ids.contains(&"nextcloud"), "{ids:?}");
assert!(ids.contains(&"folder"), "{ids:?}");
}
#[test]
fn only_the_backend_with_a_login_wants_a_credential() {
assert!(needs_secret(&Account::new("nextcloud", "https://x")));
assert!(!needs_secret(&Account::new("folder", "/mnt/photos")));
}
#[test]
fn an_account_for_an_unknown_backend_names_it() {
let e = provider_for(&Account::new("s3", "bucket"))
.err()
.expect("no such connector")
.to_string();
assert!(e.contains("s3"), "{e}");
}
}
+8 -7
View File
@@ -33,6 +33,7 @@ use slint::ComponentHandle;
use crate::settings_store::SettingsStore;
use crate::AppWindow;
use dr_sync::Connection;
/// Shared settings state for the running window.
pub struct SettingsController {
@@ -53,7 +54,8 @@ pub struct SettingsController {
/// it browses a remote tree and nothing about it is specific to what the
/// chosen folder is *for*. `None` means the picker is closed, which is
/// also the only state a device destination ever has — a path on this
/// machine is typed or chosen by the platform, not walked over WebDAV.
/// machine is typed or chosen by the platform, not walked through a
/// backend.
pub browser: RefCell<Option<crate::launch::FolderBrowser>>,
/// Polls the folder listing while one is in flight.
///
@@ -603,9 +605,9 @@ pub fn wire<F, G>(
/// List the folders under `path`, for the export destination picker.
///
/// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared
/// with it. That one reaches into the `LaunchController` for its session and
/// reports failures onto the launch screen's error line; this one is handed
/// credentials and writes to the settings page. Factoring them together would
/// with it. That one reaches into the `LaunchController` for its account and
/// reports failures onto the launch screen's error line; this one is handed a
/// connection and writes to the settings page. Factoring them together would
/// mean a function taking both controllers, or a trait implemented twice to
/// abstract two call sites — more machinery than the twenty lines it saves.
///
@@ -615,8 +617,7 @@ pub fn wire<F, G>(
pub fn spawn_folder_list(
weak: slint::Weak<AppWindow>,
ctl: Rc<SettingsController>,
creds: dr_sync_nextcloud::AppCredentials,
user_id: String,
conn: Connection,
path: String,
) {
use dr_sync::RemotePath;
@@ -637,7 +638,7 @@ pub fn spawn_folder_list(
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
+6 -8
View File
@@ -31,8 +31,8 @@ use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use dr_catalog::{trash, Catalog};
use dr_sync::{RemoteError, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Connection, RemoteError, RemoteId, RemotePath};
use dr_types::ImageId;
/// What a trash operation reports back to the UI.
@@ -162,8 +162,7 @@ pub fn plan_restore(
/// interrupted batch leaves the rows it completed correct rather than losing all
/// of them.
pub fn spawn_move(
creds: AppCredentials,
user_id: String,
conn: Connection,
moves: Vec<Move>,
direction: Direction,
catalog_path: PathBuf,
@@ -184,7 +183,7 @@ pub fn spawn_move(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
@@ -274,8 +273,7 @@ pub fn spawn_move(
/// worker can drop the previews — the shards sync, so a stale entry would keep
/// serving a preview of a deleted photograph on every device.
pub fn spawn_purge(
creds: AppCredentials,
user_id: String,
conn: Connection,
images: Vec<ImageId>,
paths: Vec<(ImageId, Option<u64>, String)>,
catalog_path: PathBuf,
@@ -297,7 +295,7 @@ pub fn spawn_purge(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
+5
View File
@@ -175,6 +175,7 @@ export component AppWindow inherits Window {
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <string> launch-server: "";
in property <string> launch-folder: "";
in property <bool> launch-busy: false;
in property <string> launch-status: "";
in property <string> launch-error: "";
@@ -184,6 +185,8 @@ export component AppWindow inherits Window {
in-out property <[bool]> launch-format-checked;
callback launch-sign-in(string);
/// The path of a folder library — no account, no credential.
callback launch-use-folder(string);
/// server, username, app password
callback launch-sign-in-direct(string, string, string);
callback launch-sign-out();
@@ -1228,6 +1231,7 @@ in property <bool> panel-visible: true;
account: root.launch-account;
library-root: root.launch-root;
server-url: root.launch-server;
folder-path: root.launch-folder;
busy: root.launch-busy;
status: root.launch-status;
error: root.launch-error;
@@ -1237,6 +1241,7 @@ in property <bool> panel-visible: true;
format-checked: root.launch-format-checked;
sign-in(server) => { root.launch-sign-in(server); }
use-folder(path) => { root.launch-use-folder(path); }
sign-in-direct(server, user, pw) => {
root.launch-sign-in-direct(server, user, pw);
}
+47 -1
View File
@@ -44,6 +44,9 @@ export component LaunchScreen inherits Rectangle {
in property <string> account: "";
in property <string> library-root: "";
in property <string> server-url: "";
// Two endpoints, shown together. Someone deciding between a server and a
// folder should not have to clear one field to try the other.
in property <string> folder-path: "";
in property <bool> busy: false;
in property <string> status: "";
in property <string> error: "";
@@ -58,6 +61,9 @@ export component LaunchScreen inherits Rectangle {
// --- events out ---
callback sign-in(string);
// A folder library: no browser, no credential, no waiting state — the
// whole sign-in is checking the directory is there.
callback use-folder(string);
/// server, username, app password
callback sign-in-direct(string, string, string);
callback sign-out();
@@ -114,7 +120,7 @@ export component LaunchScreen inherits Rectangle {
Label {
text: root.signed-in
? "Connected"
: "Connect a Nextcloud account to begin";
: "Connect a Nextcloud account, or open a folder";
body: true;
}
}
@@ -208,6 +214,46 @@ export component LaunchScreen inherits Rectangle {
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
wrap: word-wrap;
}
// --- or: a folder on this machine ---
//
// A local disk, a mounted share, or the folder the
// Nextcloud desktop client already syncs. No account and
// no credential, so this is the route that works on a
// machine with no keyring at all.
HorizontalLayout {
spacing: Theme.gap;
alignment: center;
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
Caption { text: "or"; }
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
}
PanelHeading { text: "FOLDER"; }
folder-input := Field {
text: root.folder-path;
placeholder: "/home/you/Pictures";
accepted(path) => { root.use-folder(path); }
}
FormButton {
text: "Open folder";
enabled: !root.busy && folder-input.text != "";
clicked => { root.use-folder(folder-input.text); }
}
Caption {
text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.";
wrap: word-wrap;
}
}
// --- login pending: the browser step ---
+1 -1
View File
@@ -949,7 +949,7 @@ component HeaderActions inherits HorizontalLayout {
text: root.exporting
? "Cancel export"
: (root.export-to-server
? "Export " + root.selected-count + " to Nextcloud"
? "Export " + root.selected-count + " to the library"
: "Export " + root.selected-count);
active: root.exporting;
y: root.centred ? (root.row-height - self.height) / 2 : 0;