Merge master: pluggable storage, and a name that anchors

Conflicts were docs/traceability.md alone, and it is generated — so it
was regenerated rather than hand-merged. dr-face was untouched on the
other side; ui/dr-ui/src/faces.rs and identity_ui.rs auto-merged, the
first around recluster's anchoring and the second around load_faces.

Worth recording because the two branches met on the same problem from
different ends. Master's "Let a name hold a group together" is the fix
for the sixteen Catherines — fourteen of them empty — that this branch
found while measuring the library and reported without fixing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:30:01 +02:00
co-authored by Claude Opus 5
54 changed files with 6173 additions and 986 deletions
+16 -37
View File
@@ -1167,16 +1167,11 @@ fn start_trash(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some((creds, sess)) = session() else {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
@@ -1184,7 +1179,7 @@ fn start_trash(
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_trash(cat, &sess.root, images) {
match crate::trash::plan_trash(cat, &conn.account.root, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning delete: {e}").into());
@@ -1205,11 +1200,10 @@ fn start_trash(
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
conn.clone(),
moves,
crate::trash::Direction::ToTrash,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
);
drain_trash(
@@ -1239,16 +1233,11 @@ fn start_restore(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some((creds, sess)) = session() else {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
@@ -1282,11 +1271,10 @@ fn start_restore(
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
conn.clone(),
moves,
crate::trash::Direction::Restore,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
);
drain_trash(
@@ -1466,10 +1454,7 @@ pub fn wire<S, R, P, C>(
S: Fn() + 'static,
R: Fn() -> Vec<ImageId> + 'static,
P: Fn(usize, usize) -> Vec<ImageId> + 'static,
C: Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)> + 'static,
C: Fn() -> Option<dr_sync::Connection> + 'static,
{
// Coerced to trait objects here rather than at each use: `start_trash` and
// `drain_trash` are shared by three callbacks, and a generic parameter would
@@ -1479,12 +1464,7 @@ pub fn wire<S, R, P, C>(
// A shift-click asks the catalog what lies between its two ends, and the
// catalog belongs to the grid's controller — see `span_source`.
*ctl.span_source.borrow_mut() = Some(Rc::new(span_ids));
let session: Rc<
dyn Fn() -> Option<(
dr_sync_nextcloud::AppCredentials,
dr_sync_nextcloud::Session,
)>,
> = Rc::new(session);
let session: Rc<dyn Fn() -> Option<dr_sync::Connection>> = Rc::new(session);
// --- selection ---------------------------------------------------------
{
@@ -2098,8 +2078,8 @@ pub fn wire<S, R, P, C>(
window.on_trash_empty(move || {
let Some(w) = weak.upgrade() else { return };
let (creds, sess) = match session() {
Some(s) => s,
let conn = match session() {
Some(c) => c,
None => return,
};
@@ -2131,12 +2111,11 @@ pub fn wire<S, R, P, C>(
let count = ids.len();
let rx = crate::trash::spawn_purge(
creds,
sess.user_id.clone(),
conn.clone(),
ids,
paths,
crate::library::catalog_path(&sess.server, &sess.user_id),
crate::library::thumbs_dir(&sess.server, &sess.user_id),
crate::library::catalog_path(&conn.account),
crate::library::thumbs_dir(&conn.account),
);
drain_trash(
+238 -11
View File
@@ -1,5 +1,5 @@
//! TRACES: FR-CAT-3 | FR-CAT-7 | FR-NC-7
//! Pushing derived state to Nextcloud: thumbnail shards and the catalog.
//! Pushing derived state to the library: thumbnail shards and the catalog.
//!
//! # What travels, and why only this
//!
@@ -34,8 +34,8 @@
use std::path::{Path, PathBuf};
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Connection, RemoteBackend, RemoteError, 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()));
@@ -282,7 +281,10 @@ async fn sync_shards(
}
let source = RemotePath::new(format!("{}/{name}", base.as_str()));
let bytes = match backend.get(&RemoteId::Path(source), None).await {
// Fetched where it is only a placeholder: a shard that will not open
// is a peer's thumbnails never merging, and on a library the client
// keeps dehydrated that would be every shard, every pass, silently.
let bytes = match read_derived(backend, &source).await {
Ok(b) => b,
Err(e) => {
log::warn!("downloading {name}: {e}");
@@ -465,7 +467,9 @@ async fn sync_face_shards(
)));
let source = RemotePath::new(format!("{}/{name}", face_base.as_str()));
let bytes = match backend.get(&RemoteId::Path(source), None).await {
// Fetched where it is only a placeholder, for the reason the thumbnail
// shards are: otherwise a peer's faces never arrive and nothing says so.
let bytes = match read_derived(backend, &source).await {
Ok(b) => b,
Err(e) => {
log::warn!("downloading face shard {name}: {e}");
@@ -548,7 +552,30 @@ async fn sync_catalog(
// Merging before uploading means our upload carries the union rather than
// only our own half, so a third device syncing next gets everything in one
// fetch.
if let Ok(bytes) = backend.get(&RemoteId::Path(target.clone()), None).await {
// TRACES: FR-NC-9 | FR-NC-6c
// A read that fails for any reason other than "there is not one yet" must
// stop the upload below. This is a read-modify-write over a file another
// device also writes, so skipping the read does not merely lose an
// optimisation — it turns the write into a clobber, and the other device's
// collections and their members go with it.
//
// The shape was previously `if let Ok(bytes) = ...`, which swallowed every
// failure into "no remote catalog" and carried straight on to the upload.
let theirs = match read_derived(backend, &target).await {
Ok(bytes) => Some(bytes),
// Genuinely the first sync of this library. Nothing to merge, and
// ours is the whole truth.
Err(RemoteError::NotFound(_)) => None,
Err(e) => {
log::warn!(
"not pushing the catalog: the copy on the server could not be read ({e}); \
uploading over it would discard whatever another device put there"
);
return Ok(());
}
};
if let Some(bytes) = theirs {
let downloaded = scratch.join("catalog-remote.sqlite");
if std::fs::write(&downloaded, &bytes).is_ok() {
match dr_catalog::Catalog::open(catalog_path) {
@@ -558,9 +585,19 @@ async fn sync_catalog(
report.collections_gained = merge.inserted + merge.updated;
report.members_gained = merge.members_added;
}
Err(e) => log::warn!("merging remote catalog: {e}"),
// Unreadable is not the same as absent: it may be a newer
// format, or a torn upload. Ours must not go over it.
Err(e) => {
log::warn!("not pushing the catalog: merging the server's copy: {e}");
let _ = std::fs::remove_file(&downloaded);
return Ok(());
}
},
Err(e) => log::warn!("opening catalog to merge: {e}"),
Err(e) => {
log::warn!("not pushing the catalog: opening ours to merge: {e}");
let _ = std::fs::remove_file(&downloaded);
return Ok(());
}
}
let _ = std::fs::remove_file(&downloaded);
}
@@ -587,6 +624,34 @@ async fn sync_catalog(
Ok(())
}
/// TRACES: FR-NC-6c
/// Read a derived file, fetching its content first if only a placeholder is
/// here.
///
/// Derived state lives *inside the library folder*, so on a placeholder
/// library a sync client dehydrates a shard or a catalog snapshot exactly as
/// it dehydrates a photograph. Unlike a photograph, these are ours, and none of
/// them can be skipped: a shard that will not open is face data that never
/// merges, and a catalog snapshot that will not open is the other device's
/// collections.
///
/// So this fetches rather than giving up — and where it cannot, it says so
/// with the error rather than an empty result, because the callers below treat
/// "nothing there" as licence to write their own copy (ARCH §9.0a).
async fn read_derived(
backend: &dyn RemoteBackend,
path: &RemotePath,
) -> Result<Vec<u8>, RemoteError> {
let id = RemoteId::Path(path.clone());
match backend.get(&id, None).await {
Err(RemoteError::NotMaterialised(_)) => {
backend.materialise(&id).await?;
backend.get(&id, None).await
}
other => other,
}
}
fn shard_name(client: &str, id: u32) -> String {
format!("shard-{client}-{id:04}.sqlite")
}
@@ -688,3 +753,165 @@ mod tests {
.did_anything());
}
}
#[cfg(test)]
mod catalog_guard_tests {
//! What `sync_catalog` does when it cannot read the server's copy.
//!
//! The bug these exist for was a control-flow one — `if let Ok(bytes)`
//! folding every failure into "there is none yet" and falling through to
//! the upload — so the thing to assert is not a value but *whether a write
//! happened at all*.
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
/// A backend whose read fails in a chosen way, counting writes.
struct Fussy {
fail_with: Option<RemoteError>,
puts: Arc<AtomicUsize>,
caps: dr_sync::Capabilities,
}
impl Fussy {
fn reading(fail_with: Option<RemoteError>) -> (Self, Arc<AtomicUsize>) {
let puts = Arc::new(AtomicUsize::new(0));
(
Self {
fail_with,
puts: puts.clone(),
caps: dr_sync::Capabilities::minimal(),
},
puts,
)
}
}
#[async_trait::async_trait]
impl RemoteBackend for Fussy {
fn capabilities(&self) -> &dr_sync::Capabilities {
&self.caps
}
fn name(&self) -> &str {
"fussy"
}
async fn list(
&self,
_dir: &RemotePath,
_since: Option<&dr_sync::Validator>,
) -> Result<Vec<dr_sync::RemoteEntry>, RemoteError> {
Ok(Vec::new())
}
async fn dir_validator(
&self,
_dir: &RemotePath,
) -> Result<dr_sync::Validator, RemoteError> {
Err(RemoteError::Unsupported("test"))
}
async fn delta(
&self,
_c: &dr_sync::Cursor,
) -> Result<(Vec<dr_sync::RemoteChange>, dr_sync::Cursor), RemoteError> {
Err(RemoteError::Unsupported("test"))
}
async fn get(
&self,
_id: &RemoteId,
_r: Option<std::ops::Range<u64>>,
) -> Result<Vec<u8>, RemoteError> {
match &self.fail_with {
Some(RemoteError::NotFound(s)) => Err(RemoteError::NotFound(s.clone())),
Some(RemoteError::NotMaterialised(s)) => {
Err(RemoteError::NotMaterialised(s.clone()))
}
Some(_) => Err(RemoteError::PermissionDenied),
None => Ok(Vec::new()),
}
}
async fn put(
&self,
_p: &RemotePath,
_b: Vec<u8>,
_pc: Option<dr_sync::Precondition>,
) -> Result<dr_sync::Validator, RemoteError> {
self.puts.fetch_add(1, Ordering::SeqCst);
Ok(dr_sync::Validator::new("v"))
}
async fn delete(
&self,
_id: &RemoteId,
_pc: Option<dr_sync::Precondition>,
) -> Result<(), RemoteError> {
Ok(())
}
async fn move_to(&self, _f: &RemoteId, _t: &RemotePath) -> Result<(), RemoteError> {
Ok(())
}
async fn create_dir(&self, _p: &RemotePath) -> Result<(), RemoteError> {
Ok(())
}
}
/// A real catalog and a scratch directory, since `sync_catalog` snapshots
/// one before uploading.
fn fixture(name: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("dr-catalog-guard-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("scratch")).unwrap();
let catalog_path = dir.join("catalog.sqlite");
dr_catalog::Catalog::open(&catalog_path).unwrap();
(catalog_path, dir.join("scratch"))
}
async fn run_with(fail_with: Option<RemoteError>, name: &str) -> (usize, SyncReport) {
let (catalog_path, scratch) = fixture(name);
let (backend, puts) = Fussy::reading(fail_with);
let mut report = SyncReport::default();
sync_catalog(
&backend,
&RemotePath::new(".darkroom-derived"),
&catalog_path,
&scratch,
&mut report,
)
.await
.unwrap();
let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap());
(puts.load(Ordering::SeqCst), report)
}
#[tokio::test]
async fn a_catalog_that_is_here_but_not_downloaded_is_never_written_over() {
// The bug. On a placeholder library the snapshot is dehydrated, the
// read fails, and the old code took that for "there is no remote
// catalog" and pushed ours — discarding the other device's
// collections and their members on every single sync.
let (puts, report) = run_with(
Some(RemoteError::NotMaterialised("catalog.sqlite".into())),
"notmaterialised",
)
.await;
assert_eq!(puts, 0, "must not upload over a catalog it could not read");
assert!(!report.catalog_uploaded);
assert!(!report.catalog_merged);
}
#[tokio::test]
async fn a_catalog_that_cannot_be_read_at_all_is_never_written_over() {
// Not only placeholders: a refused read, a dropped connection. Any
// failure that is not "there is none" leaves the server's copy alone.
let (puts, _) = run_with(Some(RemoteError::PermissionDenied), "denied").await;
assert_eq!(puts, 0);
}
#[tokio::test]
async fn the_first_sync_of_a_library_still_uploads() {
// The other half, and the reason `NotFound` had to stay distinct: with
// genuinely nothing on the server, ours *is* the whole truth and
// refusing to push it would mean the catalog never syncs at all.
let (puts, report) = run_with(Some(RemoteError::NotFound("nope".into())), "firstrun").await;
assert_eq!(puts, 1, "nothing to merge, so ours goes up");
assert!(report.catalog_uploaded);
}
}
+11 -13
View File
@@ -62,7 +62,7 @@ use std::time::Duration;
use dr_export::{Encoded, NameContext};
use dr_sync::RemotePath;
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection};
use dr_types::{ExportSettings, ExportTarget};
use crate::AppWindow;
@@ -72,8 +72,8 @@ use crate::AppWindow;
/// Beside the catalog, for the reason in the module docs. Per account,
/// because the destination folder is a path on one particular server and an
/// entry queued for one account is meaningless to another.
pub fn outbox_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
pub fn outbox_dir(account: &Account) -> PathBuf {
crate::library::catalog_path(account)
.parent()
.map(|p| p.join("outbox"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-outbox"))
@@ -146,7 +146,7 @@ impl Placed {
),
// Named as queued rather than exported: the file is real and
// finished, but it is not yet where the user asked for it, and
// saying "exported to Nextcloud" before it has uploaded would be
// saying "exported to the library" before it has uploaded would be
// a claim the app cannot keep if the disk is pulled.
Placed::Queued { remote_dir, .. } => {
let dir = if remote_dir.is_empty() {
@@ -318,8 +318,7 @@ pub enum UploadMessage {
/// network error would burn the whole queue against a server that is not
/// answering, and the next pass costs nothing.
pub fn spawn_upload(
creds: AppCredentials,
user_id: String,
conn: Connection,
root: String,
outbox: PathBuf,
) -> std::sync::mpsc::Receiver<UploadMessage> {
@@ -339,7 +338,7 @@ pub fn spawn_upload(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(UploadMessage::Finished {
@@ -486,7 +485,7 @@ pub struct BatchRequest {
/// Credentials for the account the library is open on. `None` where no
/// library is open, which is fine for a [`Source::Rendered`] and fatal for
/// anything that has to be fetched.
pub creds: Option<(AppCredentials, String)>,
pub conn: Option<Connection>,
pub settings: ExportSettings,
pub outbox: PathBuf,
pub sidecar_cache: PathBuf,
@@ -692,7 +691,7 @@ fn render_from_library(
cache: Option<crate::library::CacheContext>,
cancel: &Cancel,
) -> Option<Result<RenderedItem, ItemError>> {
let Some((creds, user_id)) = request.creds.clone() else {
let Some(conn) = request.conn.clone() else {
return Some(Err(ItemError::Fetch("no library is open".into())));
};
let Some(gpu) = request.gpu.as_ref() else {
@@ -706,13 +705,12 @@ fn render_from_library(
// RAW, so it costs nothing to have in hand by the time there is a session
// to apply it to.
let sidecar_rx = crate::library::spawn_sidecar_fetch(
creds.clone(),
user_id.clone(),
conn.clone(),
path.to_string(),
request.sidecar_cache.clone(),
request.offline,
);
let bytes_rx = crate::library::spawn_full_fetch(creds, user_id, path.to_string(), cache);
let bytes_rx = crate::library::spawn_full_fetch(conn, path.to_string(), cache);
let bytes = match wait_for(&bytes_rx, cancel) {
Waited::Got(Ok(bytes)) => bytes,
@@ -1332,7 +1330,7 @@ mod tests {
fn request(settings: ExportSettings, sources: Vec<Source>) -> BatchRequest {
BatchRequest {
sources,
creds: None,
conn: None,
settings,
outbox: std::env::temp_dir().join("dr-batch-test-outbox"),
sidecar_cache: std::env::temp_dir().join("dr-batch-test-sidecars"),
+78 -12
View File
@@ -506,21 +506,31 @@ pub fn recluster(
// Which faces the user has already ruled on, so they enter as anchors.
//
// Two kinds of ruling, and the second is easy to miss. A *confirmation* is
// the obvious one. But setting a person aside is a ruling too, and the
// faces it covers are only ever suggestions — so anchoring confirmations
// alone left every ignored group's faces loose, and the next Regroup
// scattered them into fresh unnamed groups that were not ignored. The
// strangers came straight back, which is the feature not working at all.
// Anything the user has ruled on anchors, and there are three ways of
// ruling — only the first of which is obvious.
//
// Anchoring them keeps them where the user put them, and does one better:
// a newly indexed face similar to a group that was set aside merges *into*
// it, so a stranger photographed again stays set aside instead of
// reappearing as somebody new.
// A **confirmation** is the plain case. **Setting a group aside** is one
// too, and the faces it covers are only ever suggestions, so anchoring
// confirmations alone let every ignored group scatter into fresh unnamed
// groups that were not ignored, and the strangers came straight back.
//
// And so is **giving a group a name**. That was the omission that did the
// most damage, because it is silent. Naming a cluster does not confirm its
// faces — they stay suggestions — so the next Regroup cut them loose,
// regrouped them into a brand new person, and left the named one holding
// nothing. `prune_empty_unnamed` will not remove it, because it has a name.
// Name the new group the same thing and it happens again. That is how one
// library came to hold sixteen people called Catherine, fourteen of them
// empty, with her faces split across the two that were not.
//
// A name is a judgement about *this group* (FR-CULL-12), exactly as an
// ignore is. Anchoring them all also does one better: a newly indexed face
// that matches a named person now merges *into* them rather than arriving
// as a stranger.
let mut confirmed = std::collections::HashMap::new();
for p in faces::people(conn)? {
// Suggestions included exactly when the group was set aside.
for f in faces::for_person(conn, p.id, p.ignored)? {
let ruled_on = p.ignored || !p.name.trim().is_empty();
for f in faces::for_person(conn, p.id, ruled_on)? {
confirmed.insert(f.id, p.id);
}
}
@@ -1178,4 +1188,60 @@ mod tests {
assert_eq!(after.len(), 1);
assert!(!after[0].ignored);
}
/// Naming a group does not confirm its faces, so before this they were
/// still only suggestions — and the next Regroup cut them loose, built a
/// new person out of them, and left the named one empty. Do that a few
/// times and the rail fills with same-named people holding nothing while
/// the faces sit under whichever one was made last.
#[test]
fn a_named_group_keeps_its_faces_through_the_next_regroup() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
let people = faces::people(catalog.connection()).unwrap();
assert_eq!(people.len(), 1);
let her = people[0].id;
assert_eq!(people[0].suggested_faces, 2);
// Named, and nothing else — no confirmations, which is what a user who
// types a name and moves on has done.
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
after.len(),
1,
"regrouping left a second person behind: {after:?}"
);
assert_eq!(after[0].id, her);
assert_eq!(after[0].name, "Catherine");
assert_eq!(
after[0].suggested_faces, 2,
"the named group lost the faces it was named for"
);
}
/// And a face found later joins the person it matches rather than arriving
/// as somebody new — the same benefit anchoring gives an ignored group.
#[test]
fn a_new_face_joins_a_named_person_rather_than_starting_a_rival() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
let her = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
put_face(&catalog, 3, 0, 0.98);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}");
assert_eq!(after[0].suggested_faces, 3);
}
}
+5 -11
View File
@@ -355,15 +355,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);
@@ -689,7 +684,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;
};
@@ -781,7 +776,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;
};
@@ -790,8 +785,7 @@ pub fn wire<S, M, P>(
*ctl.activity.borrow_mut() =
Some(activity.begin(crate::activity::Kind::Index, "Indexing faces"));
*ctl.sweep.borrow_mut() = Some(crate::library::spawn_face_sweep(
creds,
user_id,
conn,
catalog_path,
store_dir,
detector,
+7 -8
View File
@@ -55,8 +55,8 @@ use std::sync::Arc;
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection, RemoteBackend, RemotePath};
use dr_types::{FormatFilter, RootId};
/// Which root the card is granted as, and which the library is.
@@ -103,8 +103,7 @@ pub struct Request {
/// an import, and the photographs exist on disk either way.
#[derive(Clone)]
pub struct Upload {
pub credentials: AppCredentials,
pub user_id: String,
pub conn: Connection,
/// The library folder on the server. The dated folders from the template
/// are created beneath it, the same ones the local copy went into.
pub library: String,
@@ -122,8 +121,8 @@ pub struct Upload {
/// TRACES: FR-NC-7b
/// Where an account's imports wait between disk and the server.
pub fn staging_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
pub fn staging_dir(account: &Account) -> PathBuf {
crate::library::catalog_path(account)
.parent()
.map(|p| p.join("staging"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-import-staging"))
@@ -133,7 +132,7 @@ impl std::fmt::Debug for Upload {
/// Hand-written so a credential cannot reach a log through a `{:?}`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Upload")
.field("user_id", &self.user_id)
.field("account", &self.conn.account.describe())
.field("library", &self.library)
.field("thumbs", &self.thumbs)
.field("staging", &self.staging)
@@ -385,7 +384,7 @@ fn upload_all(
};
rt.block_on(async {
let backend = match crate::remote::connect(&upload.credentials, &upload.user_id) {
let backend = match crate::remote::connect(&upload.conn) {
Ok(b) => b,
Err(e) => {
log::warn!("connecting to upload: {e}");
+80 -58
View File
@@ -3,8 +3,20 @@
//! The state machine lives here, separate from the Slint bindings, so it can
//! be tested without a display server. `dr-ui` owns presentation; what a
//! login *is* belongs to the connector.
//!
//! # Two shapes of sign-in, not two screens
//!
//! A Nextcloud account is established through a browser handshake the app
//! polls for; a folder library is established by naming a directory. The model
//! below does not know which is which — it asks the
//! [`BackendProvider`](dr_sync::BackendProvider) whether the account it is
//! being asked to make needs a credential
//! ([`SignIn`](dr_sync::SignIn)), and the screen draws the waiting state only
//! where there is something to wait for. Everything after that point — picking
//! a library root, ticking formats, opening the grid — is identical, because
//! it goes through `dr-sync` rather than through a connector.
use dr_sync_nextcloud::{Session, SessionStore};
use dr_sync::{Account, AccountStore};
use dr_types::{Format, FormatFilter};
/// What the launch screen is currently doing.
@@ -18,14 +30,14 @@ pub enum LaunchState {
/// handles the password itself (FR-NC-1).
AwaitingApproval { login_url: String },
/// An account is configured.
SignedIn { session: Session },
SignedIn { session: Account },
/// Working; the reason is shown so a pause is never unexplained.
///
/// Carries the session where there is one, so a failure mid-work returns
/// to the signed-in screen rather than signing the user out.
Busy {
message: String,
session: Option<Box<Session>>,
session: Option<Box<Account>>,
},
}
@@ -53,6 +65,12 @@ pub struct LaunchModel {
pub state: LaunchState,
/// Last-used server, prefilled so a returning user need not retype it.
pub server_url: String,
/// Last-used folder, prefilled for the same reason.
///
/// Separate from `server_url` rather than one "endpoint" field, because
/// the screen shows both at once: someone deciding between the two should
/// not have to clear one to try the other.
pub folder_path: String,
pub error: Option<String>,
pub status: Option<String>,
/// False where no secrets daemon exists (FR-NC-2). The screen must say so
@@ -132,6 +150,7 @@ impl Default for LaunchModel {
Self {
state: LaunchState::SignedOut,
server_url: String::new(),
folder_path: String::new(),
error: None,
status: None,
can_remember: true,
@@ -143,14 +162,16 @@ impl Default for LaunchModel {
impl LaunchModel {
/// Build from stored sessions, resuming the last account if there is one.
pub fn from_store(store: &SessionStore) -> Self {
pub fn from_store(store: &AccountStore) -> Self {
let can_remember = store.can_remember();
match store.current() {
Some(session) => {
let filter = session.format_filter();
let (server_url, folder_path) = prefill(&session);
Self {
server_url: session.server.clone(),
server_url,
folder_path,
formats: Format::ALL
.iter()
.map(|f| (*f, filter.allows(*f)))
@@ -175,7 +196,7 @@ impl LaunchModel {
matches!(self.state, LaunchState::Busy { .. })
}
pub fn session(&self) -> Option<&Session> {
pub fn session(&self) -> Option<&Account> {
match &self.state {
LaunchState::SignedIn { session } => Some(session),
// A session survives a busy period; a scan failure must not log
@@ -245,7 +266,7 @@ impl LaunchModel {
// --- transitions ---------------------------------------------------
pub fn begin_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.server_url = server.into();
self.error = None;
self.state = LaunchState::Busy {
message: "Contacting server…".into(),
@@ -263,7 +284,7 @@ impl LaunchModel {
/// Security. That makes it the workable option where no browser can
/// complete the handshake.
pub fn begin_direct_sign_in(&mut self, server: impl Into<String>) {
self.server_url = normalise_server(&server.into());
self.server_url = server.into();
self.error = None;
self.state = LaunchState::Busy {
message: "Checking the credentials…".into(),
@@ -278,10 +299,14 @@ impl LaunchModel {
};
}
pub fn signed_in(&mut self, session: Session) {
pub fn signed_in(&mut self, session: Account) {
self.error = None;
self.status = None;
self.server_url = session.server.clone();
let (server_url, folder_path) = prefill(&session);
self.server_url = server_url;
if !folder_path.is_empty() {
self.folder_path = folder_path;
}
let filter = session.format_filter();
// Adopt the session's stored selection, so a returning user sees the
// tick-boxes they left.
@@ -357,7 +382,7 @@ impl LaunchModel {
/// Adopt the picker's current path as the library root.
///
/// Returns the session to persist, or `None` when signed out.
pub fn choose_current_folder(&mut self) -> Option<Session> {
pub fn choose_current_folder(&mut self) -> Option<Account> {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
@@ -378,25 +403,16 @@ impl LaunchModel {
}
}
/// Normalise a server address typed by hand.
/// Which of the two entry fields an account's endpoint belongs in.
///
/// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than
/// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS,
/// and an unencrypted default would be a security decision made on the user's
/// behalf without telling them.
pub fn normalise_server(input: &str) -> String {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return String::new();
}
if s.starts_with("https://") {
s.to_string()
} else if let Some(rest) = s.strip_prefix("http://") {
// Upgrade rather than accept. If the server genuinely has no TLS the
// connection fails loudly, which is the correct outcome.
format!("https://{rest}")
/// A returning user should find what they typed last time where they typed
/// it. Keyed on whether the connector has a login rather than on its id, so a
/// third backend does not have to be named here to be prefilled correctly.
fn prefill(account: &Account) -> (String, String) {
if account.login.is_empty() {
(String::new(), account.endpoint.clone())
} else {
format!("https://{s}")
(account.endpoint.clone(), String::new())
}
}
@@ -404,18 +420,17 @@ pub fn normalise_server(input: &str) -> String {
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
use dr_sync_nextcloud::AppCredentials;
use dr_sync::Secret;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example".into(),
login_name: "duncan".into(),
app_password: "token".into(),
}
fn session_with_root(root: &str) -> Account {
let mut s =
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
s.root = root.into();
s
}
fn session_with_root(root: &str) -> Session {
let mut s = Session::new(&creds(), "duncan");
fn folder_with_root(root: &str) -> Account {
let mut s = Account::new("folder", "/mnt/photos");
s.root = root.into();
s
}
@@ -513,27 +528,34 @@ mod tests {
}
#[test]
fn server_addresses_are_normalised_to_https() {
assert_eq!(normalise_server("cloud.example"), "https://cloud.example");
assert_eq!(
normalise_server("https://cloud.example/"),
"https://cloud.example"
);
assert_eq!(
normalise_server(" cloud.example "),
"https://cloud.example"
);
assert_eq!(normalise_server(""), "");
fn a_returning_user_finds_their_endpoint_where_they_typed_it() {
// Two entry fields are shown at once. Prefilling the wrong one — a
// folder path into the server box — reads as a corrupted setting.
let mut m = LaunchModel::default();
m.signed_in(session_with_root("PhotosRaw"));
assert_eq!(m.server_url, "https://cloud.example");
assert_eq!(m.folder_path, "");
let mut m = LaunchModel::default();
m.signed_in(folder_with_root("2026"));
assert_eq!(m.folder_path, "/mnt/photos");
assert_eq!(m.server_url, "");
}
#[test]
fn plain_http_is_upgraded_rather_than_accepted() {
// NFR-SEC-3: TLS is required. Failing loudly beats silently sending a
// credential in the clear.
assert_eq!(
normalise_server("http://cloud.example"),
"https://cloud.example"
);
fn a_folder_library_reaches_the_grid_the_same_way_a_server_one_does() {
// Everything past sign-in is backend-neutral, and this is the check
// that keeps it so: no branch on the account's connector below here.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.is_signed_in());
assert!(!m.can_open_library(), "no root chosen yet");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
m.signed_in(folder_with_root("2026"));
assert!(m.can_open_library());
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
assert_eq!(m.account_label(), "/mnt/photos/2026");
}
#[test]
@@ -554,13 +576,13 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
let store = AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
let mut s = session_with_root("PhotosRaw");
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2]));
store.save(&s, &creds()).unwrap();
store.save(&s, Some(&Secret::new("token"))).unwrap();
let m = LaunchModel::from_store(&store);
assert!(m.is_signed_in());
@@ -576,7 +598,7 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = SessionStore::open_at(
let store = AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
);
+171 -16
View File
@@ -8,8 +8,8 @@ use std::cell::RefCell;
use std::rc::Rc;
use dr_plat::PlatformSecretStore;
use dr_sync::RemotePath;
use dr_sync_nextcloud::{auth, Session, SessionStore};
use dr_sync::{Account, AccountStore, BackendProvider, RemotePath};
use dr_sync_nextcloud::{auth, NextcloudProvider};
use slint::ComponentHandle;
@@ -19,7 +19,7 @@ use crate::AppWindow;
/// Shared launch state for the running window.
pub struct LaunchController {
pub model: RefCell<LaunchModel>,
pub store: SessionStore,
pub store: AccountStore,
/// Holds any in-flight poll timer. A `Timer` stops when dropped, so it
/// must outlive its own callback — parking it here avoids an Rc cycle
/// between the timer and the closure it runs.
@@ -28,7 +28,7 @@ pub struct LaunchController {
impl LaunchController {
pub fn new() -> Rc<Self> {
let store = SessionStore::open(Box::new(PlatformSecretStore::new()));
let store = AccountStore::open(Box::new(PlatformSecretStore::new()));
let model = LaunchModel::from_store(&store);
Rc::new(Self {
model: RefCell::new(model),
@@ -46,6 +46,7 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
window.set_launch_account(m.account_label().into());
window.set_launch_root(m.library_root().into());
window.set_launch_server(m.server_url.clone().into());
window.set_launch_folder(m.folder_path.clone().into());
window.set_launch_busy(m.is_busy());
window.set_launch_login_url(m.login_url().into());
window.set_launch_can_remember(m.can_remember);
@@ -87,7 +88,7 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
/// the session so the caller can start a scan.
pub fn wire<F>(window: &AppWindow, controller: Rc<LaunchController>, on_open_library: F)
where
F: Fn(Session) + 'static,
F: Fn(Account) + 'static,
{
// --- sign in -------------------------------------------------------
{
@@ -96,7 +97,17 @@ where
window.on_launch_sign_in(move |server| {
log::info!("sign-in requested for {server:?}");
let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().begin_sign_in(server.to_string());
// The connector owns what a valid address is — assuming HTTPS
// here would put one backend's rule in the interface.
let server = match NextcloudProvider.normalise_endpoint(&server) {
Ok(s) => s,
Err(e) => {
ctl.model.borrow_mut().fail(e);
render(&w, &ctl);
return;
}
};
ctl.model.borrow_mut().begin_sign_in(server);
render(&w, &ctl);
let server = ctl.model.borrow().server_url.clone();
@@ -111,9 +122,15 @@ where
let ctl = controller.clone();
window.on_launch_sign_in_direct(move |server, login, password| {
let Some(w) = weak.upgrade() else { return };
ctl.model
.borrow_mut()
.begin_direct_sign_in(server.to_string());
let server = match NextcloudProvider.normalise_endpoint(&server) {
Ok(s) => s,
Err(e) => {
ctl.model.borrow_mut().fail(e);
render(&w, &ctl);
return;
}
};
ctl.model.borrow_mut().begin_direct_sign_in(server);
render(&w, &ctl);
let server = ctl.model.borrow().server_url.clone();
@@ -127,6 +144,28 @@ 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 };
match open_folder_library(&ctl.store, &path) {
Ok(account) => {
log::info!("using folder library at {}", account.endpoint);
ctl.model.borrow_mut().signed_in(account);
}
Err(e) => ctl.model.borrow_mut().fail(e),
}
render(&w, &ctl);
});
}
// --- sign out ------------------------------------------------------
{
let weak = window.as_weak();
@@ -274,6 +313,40 @@ where
render(window, &controller);
}
/// TRACES: FR-NC-13
/// Establish a folder library, returning the account to sign in as.
///
/// The whole of a [`SignIn::EndpointOnly`](dr_sync::SignIn) sign-in: check the
/// endpoint, build the account, persist it. Split out of the callback rather
/// than written inline because a Slint callback cannot be tested without a
/// display server, and this is the path that decides whether a mistyped folder
/// becomes a stored account — the failure that would then skip the launch
/// screen on the next start and surface as a library that finds nothing.
///
/// The error is a string because it goes straight to the screen's error line;
/// the connector wrote it to say what to fix.
fn open_folder_library(store: &AccountStore, path: &str) -> Result<Account, String> {
let provider = crate::remote::registry()
.get(dr_sync_folder::BACKEND_ID)
.ok_or("this build has no folder support")?;
// The connector checks the directory before an account is written for it.
let endpoint = provider.normalise_endpoint(path)?;
let account = provider.account_for(&endpoint).map_err(|e| e.to_string())?;
// `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.
//
// A failure to persist is reported, not fatal: the library opens for this
// session and the user is asked again next launch, which is a great deal
// better than refusing to open a folder that is plainly there.
if let Err(e) = store.save(&account, None) {
log::warn!("persisting account: {e}");
}
Ok(account)
}
/// Run Login Flow v2 without blocking the UI thread.
///
/// Slint's event loop is single-threaded, so the network work happens on a
@@ -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
@@ -825,3 +901,82 @@ fn android_open_url(url: &str) -> Result<(), String> {
})
.map_err(|e: jni::errors::Error| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
fn store_in(dir: &std::path::Path) -> AccountStore {
AccountStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
)
}
fn tmpdir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("dr-launch-folder-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn opening_a_folder_stores_an_account_with_no_credential() {
// The whole sign-in, end to end through the registry: no browser, no
// keyring, no waiting state.
let dir = tmpdir("ok");
let library = dir.join("Photos");
std::fs::create_dir_all(&library).unwrap();
let store = store_in(&dir);
let account = open_folder_library(&store, &library.to_string_lossy()).unwrap();
assert_eq!(account.backend, dr_sync_folder::BACKEND_ID);
assert!(account.login.is_empty(), "a folder has nobody to name");
// And it survives, so the next launch skips the screen.
let reloaded = store.current().expect("persisted");
assert_eq!(reloaded.endpoint, account.endpoint);
// With nothing in the keyring — the machine may have no secrets daemon
// at all, which is precisely when a folder library matters.
assert!(store.connection(&reloaded, false).unwrap().secret.is_none());
}
#[test]
fn a_mistyped_folder_is_refused_rather_than_stored() {
// The failure this guards: a stored account for a folder that is not
// there skips the launch screen next start and reads as a library
// that has lost its photographs.
let dir = tmpdir("typo");
let store = store_in(&dir);
let err = open_folder_library(&store, &dir.join("Pictrues").to_string_lossy()).unwrap_err();
assert!(err.contains("No folder"), "{err}");
assert!(store.current().is_none(), "nothing may be persisted");
}
#[test]
fn the_error_says_what_to_fix() {
// It goes straight to the screen's error line, so it has to read as
// instruction rather than as a type name.
let dir = tmpdir("messages");
let store = store_in(&dir);
for (input, want) in [("", "Choose"), ("Pictures", "full path")] {
let err = open_folder_library(&store, input).unwrap_err();
assert!(err.contains(want), "{input:?} gave {err:?}");
}
}
#[test]
fn a_file_is_not_a_library() {
let dir = tmpdir("file");
let f = dir.join("a.CR2");
std::fs::write(&f, b"raw").unwrap();
let store = store_in(&dir);
let err = open_folder_library(&store, &f.to_string_lossy()).unwrap_err();
assert!(err.contains("not a folder"), "{err}");
}
}
+37 -49
View File
@@ -406,10 +406,10 @@ fn batch_request(
export::BatchRequest {
sources,
creds: library.credentials(),
conn: library.credentials(),
settings: stored.export,
outbox: match library.session() {
Some((_, s)) => export::outbox_dir(&s.server, &s.user_id),
Some(c) => export::outbox_dir(&c.account),
// No account, so no outbox — a device export still works, and a
// remote one is refused by `place` rather than here, so the message
// names the setting rather than the plumbing.
@@ -434,15 +434,16 @@ fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
if library.is_offline() {
return;
}
let Some((creds, session)) = library.session() else {
let Some(conn) = library.session() else {
return;
};
let outbox = export::outbox_dir(&session.server, &session.user_id);
let outbox = export::outbox_dir(&conn.account);
if export::pending_count(&outbox) == 0 {
return;
}
let rx = export::spawn_upload(creds, session.user_id.clone(), session.root.clone(), outbox);
let root = conn.account.root.clone();
let rx = export::spawn_upload(conn, root, outbox);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
@@ -472,7 +473,9 @@ fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsC
let remote = stored.export.target == dr_types::ExportTarget::Remote;
window.set_export_label(
if remote {
"Export to Nextcloud"
// Not the connector's name: this is a Nextcloud account for some
// libraries and a folder on a mount for others.
"Export to the library"
} else {
"Export"
}
@@ -1036,13 +1039,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
library.catalog(),
activity.clone(),
move || {
let (_, session) = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(
&session.server,
&session.user_id,
))
.ok()
.map(std::rc::Rc::new)
let conn = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account))
.ok()
.map(std::rc::Rc::new)
},
// The weights are not shipped and are not a build input
// (docs/faces.md §2): the user puts them beside the catalog,
@@ -1050,24 +1050,21 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
{
let lib = library.clone();
move || {
let (_, session) = lib.session()?;
library::face_models(&session.server, &session.user_id)
let conn = lib.session()?;
library::face_models(&conn.account)
}
},
// The sweep opens its own connection on its own thread, so it
// takes paths rather than the handles this screen holds — and
// credentials, because it fetches the pixels it indexes rather
// a connection, because it fetches the pixels it indexes rather
// than reading whatever the grid happened to leave behind.
{
let lib = library.clone();
move || {
let (creds, session) = lib.session()?;
Some((
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
library::thumbs_dir(&session.server, &session.user_id),
))
let conn = lib.session()?;
let catalog = library::catalog_path(&conn.account);
let thumbs = library::thumbs_dir(&conn.account);
Some((conn, catalog, thumbs))
}
},
);
@@ -1100,7 +1097,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Before anything opens a store: an upgrade must not
// abandon a catalog, its thumbnails, or the offline
// ratings and edits waiting beside them.
library::migrate_legacy_cache_data(&session.server, &session.user_id);
library::migrate_legacy_cache_data(&session);
library_ui::open(
&window,
library.clone(),
@@ -1147,23 +1144,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
&window,
import,
move || {
let (creds, session) = library_for_context.session()?;
let conn = library_for_context.session()?;
Some(import_ui::Context {
catalog: library::catalog_path(&session.server, &session.user_id),
library_label: session.root.clone(),
catalog: library::catalog_path(&conn.account),
library_label: conn.account.root.clone(),
// The same formats the scan looks for. An import that took
// types the library then ignores would copy files off the
// card that never appear in the grid.
filter: session.format_filter(),
filter: conn.account.format_filter(),
upload: Some(import::Upload {
credentials: creds,
user_id: session.user_id.clone(),
library: session.root.clone(),
library: conn.account.root.clone(),
// The same shard store the grid reads and the sync
// pushes, so a thumbnail made during an import is the
// one every other client gets.
thumbs: library::thumbs_dir(&session.server, &session.user_id),
staging: import::staging_dir(&session.server, &session.user_id),
thumbs: library::thumbs_dir(&conn.account),
staging: import::staging_dir(&conn.account),
conn,
}),
})
},
@@ -1218,14 +1214,8 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
library: &Rc<library_ui::LibraryController>,
path: String| {
match library.session() {
Some((creds, session)) => {
settings_ui::spawn_folder_list(
weak.clone(),
ctl.clone(),
creds,
session.user_id.clone(),
path,
);
Some(conn) => {
settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, path);
}
None => {
// No account, so nothing to browse. Said plainly rather
@@ -1364,14 +1354,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let lib = library.clone();
let catalog = library.catalog();
move |w: &AppWindow| {
let store = lib.session().and_then(|(_, s)| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&s.server, &s.user_id))
.ok()
let store = lib.session().and_then(|c| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok()
});
identity_ui::refresh_coverage(w, &catalog, store.as_ref());
w.set_identity_model_missing(
lib.session()
.and_then(|(_, s)| library::face_models(&s.server, &s.user_id))
.and_then(|c| library::face_models(&c.account))
.is_none(),
);
}
@@ -1780,7 +1769,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
w.set_index(0);
w.set_total(1);
let Some((creds, user_id)) = library.credentials() else {
let Some(conn) = library.credentials() else {
w.set_load_error("no library session".into());
return;
};
@@ -1809,8 +1798,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// photograph is, instead of the image appearing at its defaults
// and visibly changing a moment later.
let sidecar_rx = library::spawn_sidecar_fetch(
creds.clone(),
user_id.clone(),
conn.clone(),
path.clone(),
library.sidecar_cache_dir().unwrap_or_default(),
library.is_offline(),
@@ -1829,7 +1817,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
log::info!("fetching {path} for develop");
w.set_load_error("Downloading…".into());
let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache);
let rx = library::spawn_full_fetch(conn, path.clone(), cache);
// The one transfer the user is actively waiting on. It gets a row
// like any other, so a download that is still running after they
+307 -79
View File
@@ -25,8 +25,7 @@ use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use dr_catalog::{Catalog, JobKind, Priority};
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Account, Connection, RemoteBackend, RemoteError, 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 {
@@ -950,7 +944,35 @@ async fn drain_one(
let path = RemotePath::new(path_str.to_string());
let id = RemoteId::Path(path.clone());
let remote = backend.get(&id, None).await.ok();
// TRACES: FR-NC-6c
// A miss and a placeholder are not the same answer, and conflating them
// destroys work. This read decides whether the sidecar already on the
// remote is merged in; treating "the content is not on this device" as
// "there is no sidecar" writes a fresh document over an existing one and
// discards every edit another device put there — the exact loss the
// format's unknown-key preservation exists to prevent.
//
// A sidecar is a few kilobytes, so the right response to a placeholder is
// to fetch it, not to give up. Where that is impossible — no client
// running — the entry stays queued, which is what the outbox is for.
let remote = match backend.get(&id, None).await {
Ok(bytes) => Some(bytes),
Err(RemoteError::NotFound(_)) => None,
Err(RemoteError::NotMaterialised(_)) => {
backend
.materialise(&id)
.await
.map_err(|e| format!("sidecar is not on this device ({e})"))?;
match backend.get(&id, None).await {
Ok(bytes) => Some(bytes),
Err(e) => return Err(format!("sidecar could not be read ({e})")),
}
}
// Anything else — a refused read, a dead connection — leaves the entry
// queued rather than resolved by overwriting.
Err(e) => return Err(format!("sidecar could not be read ({e})")),
};
if let Some(bytes) = remote.as_deref() {
if !bytes.is_empty() {
@@ -1001,20 +1023,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 +1051,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 +1060,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 +1083,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 +1133,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 +1142,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 +1184,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 +1199,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 +1483,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 +1544,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 {
@@ -1552,6 +1565,48 @@ pub fn spawn_pin_fetch(
};
let id = RemoteId::Path(RemotePath::new(&source_ref));
// TRACES: FR-NC-6c
// On a placeholder library "pin" means *keep it downloaded*,
// not "make a second copy". The original materialises in the
// library folder itself, so copying it under `originals/`
// would hold every pinned photograph twice — and the copy
// would be the half the budget could evict while the real disk
// cost stayed. Only the bookkeeping is recorded, with no path,
// so nothing here can ever delete a file inside a synced tree
// (see `Cache::record_in_place`).
if backend.capabilities().materialisation.can_materialise() {
match backend.materialise(&id).await {
Ok(_) => {
let bytes = size_of(&catalog, image).unwrap_or(0);
if let Err(e) = store.record_in_place(
catalog.connection(),
image,
bytes,
true,
now_secs(),
) {
log::warn!("recording pinned {source_ref}: {e}");
continue;
}
stored += 1;
bytes_total += bytes;
if tx.send(PinMessage::Stored { done: stored }).is_err() {
return;
}
}
Err(e) if e.indicates_offline() => {
let _ = tx.send(PinMessage::Failed {
message: e.to_string(),
offline: true,
});
return;
}
Err(e) => log::warn!("pinning {source_ref}: {e}"),
}
continue;
}
match backend.get(&id, None).await {
Ok(bytes) => {
// `pinned: true` — this is the population the budget
@@ -1601,6 +1656,82 @@ pub fn spawn_pin_fetch(
rx
}
/// TRACES: FR-NC-6c
/// Hand a set of photographs back to the sync client, freeing their disk.
///
/// The other half of pinning on a placeholder library. `Cache::release` drops
/// the bookkeeping and — correctly — deletes nothing, because the rows it
/// holds for a library like this name no file of ours (`record_in_place`).
/// The bytes are in the library folder, and only the client may take them
/// back.
///
/// **This is a dehydration, not a deletion, and the distinction is the whole
/// safety of the feature.** Removing a materialised file inside a synced tree
/// propagates to the server and deletes the photograph everywhere.
///
/// Best effort per image: a file the client refuses to release simply stays,
/// which costs disk and loses nothing.
pub fn spawn_dehydrate(
conn: Connection,
catalog_path: PathBuf,
images: Vec<dr_types::ImageId>,
) -> Receiver<usize> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let Ok(catalog) = Catalog::open(&catalog_path) else {
return;
};
let Ok(rt) = crate::net_runtime::build() else {
return;
};
rt.block_on(async {
let Ok(backend) = crate::remote::connect(&conn) else {
return;
};
// Nothing to do where content is not a thing that can be given
// back — a server library, or a plain folder.
if !backend.capabilities().materialisation.can_materialise() {
return;
}
let mut released = 0usize;
for image in images {
let Some(source_ref) = source_ref_of(&catalog, image) else {
continue;
};
let id = RemoteId::Path(RemotePath::new(&source_ref));
match backend.dematerialise(&id).await {
Ok(()) => released += 1,
Err(e) => log::debug!("releasing {source_ref}: {e}"),
}
}
log::info!("released {released} photograph(s) back to the sync client");
let _ = tx.send(released);
});
});
rx
}
/// What an image occupies, as the catalog recorded it.
///
/// Zero where the scan could not tell — a placeholder reports no size, because
/// a one-byte stub says nothing about what it stands for (ARCH §9.0a). A pin
/// that cannot state its cost is better than one that states a wrong one.
fn size_of(catalog: &Catalog, image: dr_types::ImageId) -> Option<u64> {
catalog
.connection()
.query_row(
"SELECT file_size FROM images WHERE id = ?1",
rusqlite::params![image.0 as i64],
|r| r.get::<_, Option<i64>>(0),
)
.ok()
.flatten()
.map(|v| v.max(0) as u64)
}
/// The remote path for a catalogued image.
fn source_ref_of(catalog: &Catalog, image: dr_types::ImageId) -> Option<String> {
catalog
@@ -1678,8 +1809,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 +1850,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 +1899,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 +1937,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 +1980,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 +2086,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 +2615,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 +2653,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 +2986,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 +3114,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}");
@@ -3012,6 +3135,13 @@ pub fn spawn_face_sweep(
let (mut done, mut images, mut found, mut failed) = (0usize, 0usize, 0usize, 0usize);
let mut offline = false;
// TRACES: FR-NC-6c
// The same borrow the thumbnail sweep makes, and deliberately its
// own pool: the two passes run at different times, so sharing one
// would keep every file the earlier pass touched hydrated until
// the later one finished. Each gives its own back (ARCH §9.0a).
let pool = dr_sync_folder::BorrowPool::new();
for chunk in wanted.chunks(SWEEP_CHUNK) {
let lanes: Vec<Vec<&ThumbnailRequest>> = (0..SWEEP_LANES)
.map(|lane| chunk.iter().skip(lane).step_by(SWEEP_LANES).collect())
@@ -3021,6 +3151,7 @@ pub fn spawn_face_sweep(
let backend = &*backend;
let models = &models;
let options = &options;
let pool = &pool;
async move {
let mut indexed: Vec<IndexedImage> = Vec::new();
let mut discard = Vec::new();
@@ -3029,6 +3160,23 @@ pub fn spawn_face_sweep(
let mut offline = false;
for req in lane {
attempted += 1;
let _held =
match pool.borrow(backend, &RemotePath::new(&req.path)).await {
Ok(h) => h,
Err(e) if e.indicates_offline() => {
log::info!("face sweep: {e}");
attempted -= 1;
offline = true;
break;
}
Err(e) => {
log::debug!("face sweep: {}: {e}", req.path);
failed += 1;
continue;
}
};
match fetch_preview(backend, req, &mut discard).await {
PreviewOutcome::Ready(mut preview) => {
// No await inside this borrow — see the
@@ -3133,8 +3281,13 @@ pub fn spawn_face_sweep(
{
// Receiver dropped: the screen closed, or
// the user pressed Stop. Everything written
// so far stays written.
// so far stays written — and everything
// borrowed is given back. A cancelled pass
// that kept the library hydrated would be
// the worst of both: the disk spent and
// the work abandoned.
log::info!("face sweep: cancelled after {images} image(s)");
pool.release_all(&*backend).await;
return;
}
}
@@ -3152,6 +3305,14 @@ pub fn spawn_face_sweep(
}
}
let returned = pool.release_all(&*backend).await;
if returned.released > 0 {
log::info!(
"face sweep: released {} borrowed file(s)",
returned.released
);
}
log::info!(
"face sweep: {found} face(s) across {images} image(s), {failed} failed{}",
if offline { ", server went away" } else { "" }
@@ -3226,8 +3387,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 +3460,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}");
@@ -3313,6 +3473,15 @@ pub fn spawn_thumbnail_sweep(
let mut offline = false;
let mut found = Vec::new();
// TRACES: FR-NC-6c
// On a placeholder library the bytes may not be here at all, and
// this is a pass the user asked for — so it may fetch them, which
// browsing may not (ARCH §9.0a). Every file is *borrowed*: what
// this pass downloads it gives back, and what the user already had
// it leaves alone. Against a server or a plain folder every borrow
// is a no-op, so there is one code path rather than two.
let pool = dr_sync_folder::BorrowPool::new();
for chunk in wanted.chunks(SWEEP_CHUNK) {
// Each lane owns a disjoint slice and its own output, so
// nothing is shared and no lock is needed. The store is not
@@ -3323,6 +3492,7 @@ pub fn spawn_thumbnail_sweep(
let results = futures_join_all(lanes.into_iter().map(|lane| {
let backend: &dyn RemoteBackend = &*backend;
let pool = &pool;
async move {
let mut made: Vec<(u64, dr_thumbs::Thumbnail)> = Vec::new();
let mut found = Vec::new();
@@ -3335,6 +3505,27 @@ pub fn spawn_thumbnail_sweep(
// store on and is not a candidate.
let Some(file_id) = req.file_id else { continue };
attempted += 1;
// Held for this image only. A failure to fetch is
// this image's verdict, not the batch's: a client
// that cannot reach the server reports it as
// offline through the usual path below.
let _held =
match pool.borrow(backend, &RemotePath::new(&req.path)).await {
Ok(h) => h,
Err(e) if e.indicates_offline() => {
log::info!("thumbnail sweep: {e}");
attempted -= 1;
offline = true;
break;
}
Err(e) => {
log::debug!("thumbnail sweep: {}: {e}", req.path);
failed += 1;
continue;
}
};
match fetch_preview(backend, req, &mut found).await {
PreviewOutcome::Ready(preview) => {
match encode_preview(file_id, &preview) {
@@ -3385,6 +3576,10 @@ pub fn spawn_thumbnail_sweep(
.send(ThumbSweepMessage::Progress { done, stored })
.is_err()
{
// Cancelled. Hand back what was borrowed before leaving,
// or a stopped pass costs the disk of everything it had
// reached and delivers nothing for it.
pool.release_all(&*backend).await;
return;
}
if offline {
@@ -3393,6 +3588,19 @@ pub fn spawn_thumbnail_sweep(
}
flush_sweep(&catalog, &mut found);
// Give back everything this pass fetched, before reporting done —
// a user watching the disk should see it return, and a pass that
// reported success while still holding the library would be
// lying about what it cost.
let returned = pool.release_all(&*backend).await;
if returned.released > 0 {
log::info!(
"thumbnail sweep: released {} borrowed file(s)",
returned.released
);
}
log::info!("thumbnail sweep: {stored} stored, {failed} without a usable preview");
let _ = tx.send(ThumbSweepMessage::Finished {
stored,
@@ -3457,8 +3665,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 +3679,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 +3719,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 +4510,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 +4599,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 +4613,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 +5256,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());
}
@@ -5445,6 +5672,7 @@ mod tests {
size,
modified: None,
has_preview: false,
materialised: true,
}
}
+88 -89
View File
@@ -17,7 +17,7 @@ use std::rc::Rc;
use std::sync::mpsc::Receiver;
use dr_catalog::Catalog;
use dr_sync_nextcloud::{AppCredentials, Session, SessionStore};
use dr_sync::{Account, AccountStore, Connection};
use dr_types::FormatFilter;
use slint::{ComponentHandle, Model as _};
@@ -177,7 +177,12 @@ pub struct LibraryController {
/// Pushing shards and the catalog to the server.
sync_timer: RefCell<Option<slint::Timer>>,
/// Kept so a rescan can run without going back through the launch screen.
session: RefCell<Option<(AppCredentials, Session, FormatFilter)>>,
///
/// A [`Connection`] rather than credentials beside an account: it is what
/// every worker needs, it is what `remote::connect` takes, and holding the
/// two halves separately is how they came to be threaded through fifteen
/// signatures in the wrong order.
session: RefCell<Option<(Connection, FormatFilter)>>,
/// Which collection narrows the grid, owned by [`crate::collections_ui`]
/// and read here. Shared rather than passed per call because a rescan, a
/// scrub and a drop all reload the window and must all honour it.
@@ -506,8 +511,8 @@ impl LibraryController {
/// (FR-NC-6a), and a queued edit must never be.
pub fn sidecar_cache_dir(&self) -> Option<PathBuf> {
let borrow = self.session.borrow();
let (_, session, _) = borrow.as_ref()?;
library::catalog_path(&session.server, &session.user_id)
let (conn, _) = borrow.as_ref()?;
library::catalog_path(&conn.account)
.parent()
.map(|p| p.join("sidecars"))
}
@@ -520,8 +525,8 @@ impl LibraryController {
/// catalog row is gone is unreachable anyway.
pub fn cache_dir(&self) -> Option<PathBuf> {
let borrow = self.session.borrow();
let (_, session, _) = borrow.as_ref()?;
library::catalog_path(&session.server, &session.user_id)
let (conn, _) = borrow.as_ref()?;
library::catalog_path(&conn.account)
.parent()
.map(|p| p.join("originals"))
}
@@ -565,8 +570,8 @@ impl LibraryController {
/// three hundred would re-download every one of them.
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
let borrow = self.session.borrow();
let (_, session, _) = borrow.as_ref()?;
let catalog_path = library::catalog_path(&session.server, &session.user_id);
let (conn, _) = borrow.as_ref()?;
let catalog_path = library::catalog_path(&conn.account);
let dir = catalog_path.parent()?.join("originals");
drop(borrow);
@@ -602,15 +607,12 @@ impl LibraryController {
self.catalog.clone()
}
/// Credentials and session for the open library.
/// The open library's connection.
///
/// Needed by the trash, whose `MOVE` and `DELETE` go to the same account the
/// Needed by the trash, whose move and delete go to the same account the
/// scan and thumbnail workers use. `None` before a library is opened.
pub fn session(&self) -> Option<(AppCredentials, Session)> {
self.session
.borrow()
.as_ref()
.map(|(c, s, _)| (c.clone(), s.clone()))
pub fn session(&self) -> Option<Connection> {
self.session.borrow().as_ref().map(|(c, _)| c.clone())
}
/// Catalog ids of the rows currently in the model, in model order.
@@ -730,16 +732,12 @@ impl LibraryController {
.collect()
}
/// Credentials and account for the open library, if one is open.
/// The open library's connection, for a full-file fetch.
///
/// What a full-file fetch needs: the grid's paths are remote, so opening
/// an image means downloading it, and that needs the same session the
/// thumbnail workers use.
pub fn credentials(&self) -> Option<(AppCredentials, String)> {
self.session
.borrow()
.as_ref()
.map(|(creds, session, _)| (creds.clone(), session.user_id.clone()))
/// The grid's paths are remote, so opening an image means fetching it, and
/// that goes through the same account the thumbnail workers use.
pub fn credentials(&self) -> Option<Connection> {
self.session.borrow().as_ref().map(|(c, _)| c.clone())
}
/// Narrow the grid to a collection, or to the whole library with `None`.
@@ -786,10 +784,13 @@ pub fn open(
window: &AppWindow,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
store: &SessionStore,
session: Session,
store: &AccountStore,
account: Account,
) {
let creds = match store.credentials(&session) {
// The credential is fetched only where the connector wants one; a folder
// library has none, and asking the keyring for it would fail the one
// backend that needs nothing.
let conn = match store.connection(&account, crate::remote::needs_secret(&account)) {
Ok(c) => c,
Err(e) => {
window.set_library_error(format!("credentials: {e}").into());
@@ -798,8 +799,8 @@ pub fn open(
}
};
let filter = session.format_filter();
*ctl.session.borrow_mut() = Some((creds.clone(), session.clone(), filter.clone()));
let filter = account.format_filter();
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
window.set_show_library(true);
window.set_library_open(true);
@@ -809,10 +810,10 @@ pub fn open(
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(
if session.root.is_empty() {
format!("{} · whole account", session.user_id)
if conn.account.root.is_empty() {
format!("{} · whole account", conn.account.user_id)
} else {
format!("{}/{}", session.user_id, session.root)
format!("{}/{}", conn.account.user_id, conn.account.root)
}
.into(),
);
@@ -825,13 +826,13 @@ pub fn open(
return;
}
let path = library::catalog_path(&session.server, &session.user_id);
let path = library::catalog_path(&conn.account);
log::info!(
"scanning {} for {} format(s) → {}",
if session.root.is_empty() {
if conn.account.root.is_empty() {
"<account root>"
} else {
&session.root
&conn.account.root
},
filter.iter().count(),
path.display()
@@ -842,9 +843,8 @@ pub fn open(
show_catalog_now(window, &ctl, &path, &coll_ctl);
let rx = library::spawn_scan(
creds,
session.user_id.clone(),
session.root.clone(),
conn.clone(),
conn.account.root.clone(),
filter,
path.clone(),
);
@@ -870,8 +870,8 @@ fn drain_scan(
// Named after the folder, because two accounts or two roots produce rows
// that are otherwise identical.
let title = match ctl.session.borrow().as_ref() {
Some((_, session, _)) if !session.root.is_empty() => {
format!("Scanning {}", session.root)
Some((c, _)) if !c.account.root.is_empty() => {
format!("Scanning {}", c.account.root)
}
_ => "Scanning the library".to_string(),
};
@@ -1044,7 +1044,7 @@ fn start_rescan(
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some((creds, session, filter)) = ctl.session.borrow().clone() else {
let Some((conn, filter)) = ctl.session.borrow().clone() else {
return;
};
@@ -1052,11 +1052,10 @@ fn start_rescan(
window.set_library_error(slint::SharedString::new());
window.set_library_status("Rescanning…".into());
let path = library::catalog_path(&session.server, &session.user_id);
let path = library::catalog_path(&conn.account);
let rx = library::spawn_scan(
creds,
session.user_id.clone(),
session.root.clone(),
conn.clone(),
conn.account.root.clone(),
filter,
path.clone(),
);
@@ -1327,13 +1326,30 @@ fn release_collection_offline(
}
};
let images = collection_images(catalog, &ids);
match cache.release(catalog.connection(), &images) {
let outcome = match cache.release(catalog.connection(), &images) {
Ok(r) => r,
Err(e) => {
window.set_library_error(format!("removing local copies: {e}").into());
return;
}
};
// TRACES: FR-NC-6c
// On a placeholder library the bookkeeping above owns no files, so it
// freed nothing — the originals are materialised in the library folder
// and only the sync client may take them back. Asking it to is what
// makes unpinning actually return the disk, and it must be a
// dehydration rather than a delete: removing a file inside a synced
// tree propagates to the server (ARCH §9.0a).
//
// Fire and forget: it is per-file work over a socket, the user has
// already been told the pin is withdrawn, and a client that refuses
// leaves the content where it is at no cost but disk.
if let Some(conn) = ctl.session() {
let path = library::catalog_path(&conn.account);
std::mem::drop(library::spawn_dehydrate(conn, path, images));
}
outcome
};
let (count, freed) = released;
@@ -1413,7 +1429,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 +1444,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 +1644,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 +2779,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 +2787,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 +2921,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 +2981,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 +3035,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 +3346,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 +3365,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 +3383,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 +3408,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 +3533,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 +3548,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 +3640,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 +3664,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();
+90 -22
View File
@@ -1,4 +1,4 @@
// TRACES: FR-NC-12
// TRACES: FR-NC-12 | FR-NC-13
//! The one place the interface names a backend.
//!
//! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts
@@ -9,32 +9,100 @@
//! 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, NextcloudVfs};
/// 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));
// TRACES: FR-NC-6c
// The folder connector does the filesystem work and knows nothing
// about sync clients; the placeholder convention is supplied here,
// which is the one place that may name one. Detection is per folder
// and per connection — the same directory offers hydration while the
// client is running and not while it is down (ARCH §9.0).
r.register(Arc::new(FolderProvider::with_vfs_detector(|root| {
NextcloudVfs::looks_synced(root)
.then(|| Arc::new(NextcloudVfs::detect()) as Arc<dyn dr_sync_folder::Vfs>)
})));
r
})
}
/// The connector serving an account.
///
/// Errors when this build has none — a configuration file outlives the binary
/// that wrote it, and saying *which* backend is missing beats "could not open
/// library".
pub fn provider_for(account: &Account) -> Result<&'static Arc<dyn BackendProvider>, RemoteError> {
registry().for_account(account)
}
/// Whether an account's credential has to be fetched from secure storage.
///
/// Asked of the connector rather than inferred from the account, because an
/// empty login might be a folder library or might be a damaged record, and
/// guessing turns the second into a silent unauthenticated connection.
pub fn needs_secret(account: &Account) -> bool {
provider_for(account).is_ok_and(|p| p.sign_in().needs_secret())
}
/// Open a connection to a configured remote.
///
/// Returns the trait object every caller should hold. The error type is
/// `dr-sync`'s rather than the connector's, so a caller handles a failure
/// `dr-sync`'s rather than a connector's, so a caller handles a failure
/// without learning which backend produced it.
pub(crate) fn connect(
creds: &AppCredentials,
user_id: &str,
) -> Result<Box<dyn RemoteBackend>, RemoteError> {
Ok(Box::new(NextcloudBackend::new(creds, user_id)?))
pub(crate) fn connect(conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
registry().connect(conn)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_backends_are_registered() {
// The list the launch screen offers. A backend missing from here is a
// backend the user cannot choose, however complete its connector is.
let ids: Vec<&str> = registry().providers().iter().map(|p| p.id()).collect();
assert!(ids.contains(&"nextcloud"), "{ids:?}");
assert!(ids.contains(&"folder"), "{ids:?}");
}
#[test]
fn only_the_backend_with_a_login_wants_a_credential() {
assert!(needs_secret(&Account::new("nextcloud", "https://x")));
assert!(!needs_secret(&Account::new("folder", "/mnt/photos")));
}
#[test]
fn an_account_for_an_unknown_backend_names_it() {
let e = provider_for(&Account::new("s3", "bucket"))
.err()
.expect("no such connector")
.to_string();
assert!(e.contains("s3"), "{e}");
}
}
+8 -7
View File
@@ -33,6 +33,7 @@ use slint::ComponentHandle;
use crate::settings_store::SettingsStore;
use crate::AppWindow;
use dr_sync::Connection;
/// Shared settings state for the running window.
pub struct SettingsController {
@@ -53,7 +54,8 @@ pub struct SettingsController {
/// it browses a remote tree and nothing about it is specific to what the
/// chosen folder is *for*. `None` means the picker is closed, which is
/// also the only state a device destination ever has — a path on this
/// machine is typed or chosen by the platform, not walked over WebDAV.
/// machine is typed or chosen by the platform, not walked through a
/// backend.
pub browser: RefCell<Option<crate::launch::FolderBrowser>>,
/// Polls the folder listing while one is in flight.
///
@@ -603,9 +605,9 @@ pub fn wire<F, G>(
/// List the folders under `path`, for the export destination picker.
///
/// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared
/// with it. That one reaches into the `LaunchController` for its session and
/// reports failures onto the launch screen's error line; this one is handed
/// credentials and writes to the settings page. Factoring them together would
/// with it. That one reaches into the `LaunchController` for its account and
/// reports failures onto the launch screen's error line; this one is handed a
/// connection and writes to the settings page. Factoring them together would
/// mean a function taking both controllers, or a trait implemented twice to
/// abstract two call sites — more machinery than the twenty lines it saves.
///
@@ -615,8 +617,7 @@ pub fn wire<F, G>(
pub fn spawn_folder_list(
weak: slint::Weak<AppWindow>,
ctl: Rc<SettingsController>,
creds: dr_sync_nextcloud::AppCredentials,
user_id: String,
conn: Connection,
path: String,
) {
use dr_sync::RemotePath;
@@ -637,7 +638,7 @@ pub fn spawn_folder_list(
return;
};
rt.block_on(async {
match crate::remote::connect(&creds, &user_id) {
match crate::remote::connect(&conn) {
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries
+6 -8
View File
@@ -31,8 +31,8 @@ use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use dr_catalog::{trash, Catalog};
use dr_sync::{RemoteError, RemoteId, RemotePath};
use dr_sync_nextcloud::AppCredentials;
use dr_sync::{Connection, RemoteError, RemoteId, RemotePath};
use dr_types::ImageId;
/// What a trash operation reports back to the UI.
@@ -162,8 +162,7 @@ pub fn plan_restore(
/// interrupted batch leaves the rows it completed correct rather than losing all
/// of them.
pub fn spawn_move(
creds: AppCredentials,
user_id: String,
conn: Connection,
moves: Vec<Move>,
direction: Direction,
catalog_path: PathBuf,
@@ -184,7 +183,7 @@ pub fn spawn_move(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {
@@ -274,8 +273,7 @@ pub fn spawn_move(
/// worker can drop the previews — the shards sync, so a stale entry would keep
/// serving a preview of a deleted photograph on every device.
pub fn spawn_purge(
creds: AppCredentials,
user_id: String,
conn: Connection,
images: Vec<ImageId>,
paths: Vec<(ImageId, Option<u64>, String)>,
catalog_path: PathBuf,
@@ -297,7 +295,7 @@ pub fn spawn_purge(
};
rt.block_on(async {
let backend = match crate::remote::connect(&creds, &user_id) {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(TrashMessage::Done {