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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user