Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.
dr-plat SecretStore trait plus a Secret Service backend.
Verified against the live GNOME Keyring: store,
retrieve, delete, confirm-gone all round-trip.
Session/SessionStore splits credentials from settings — the app
password goes to the keyring (FR-NC-2), while
server, login, chosen root and format selection are
ordinary config. A test asserts the credential never
appears in the config file.
LaunchModel the launch-screen state machine, testable without a
display server: sign in, approve in browser, choose
folder, tick formats, sign out.
launch.slint the screen itself, in its own file.
Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.
Two bugs caught by tests rather than by running it:
- fail() after busy() signed the user out, because busy() had already
discarded the session. A failed *scan* would have logged you out.
Busy now carries the session.
- normalise_server upgrades http:// to https:// rather than accepting
it. NFR-SEC-3 requires TLS, and silently sending a credential in the
clear is not a decision to make on the user's behalf.
launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.
419 tests passing across ten crates.
This commit is contained in:
+74
-3
@@ -458,7 +458,12 @@ Not "on first connect" as a bulk operation. Thumbnails are generated:
|
||||
|
||||
For a local library this converges on "everything, eventually", because scrolling reaches everything
|
||||
and the background pass has nothing else to do. For a remote library it converges on "what you
|
||||
actually browsed", which is the difference between a few hundred megabytes and a hundred gigabytes.
|
||||
actually browsed".
|
||||
|
||||
**Measured on a real 17,185-RAW library, 2026-08-09:** cataloguing it by whole-file fetch would move
|
||||
roughly **370 GB**; the range-extract path moves a few MB for the images actually viewed. This is
|
||||
the single largest cost difference in the design, and it is why §7.1 is a list of narrow triggers
|
||||
rather than "generate them all on connect".
|
||||
|
||||
### 7.2 How, by availability
|
||||
|
||||
@@ -466,7 +471,7 @@ actually browsed", which is the difference between a few hundred megabytes and a
|
||||
|---|---|---|
|
||||
| `Original`, local | Embedded JPEG via `dr-decode` preview path | ~200 KB read, no demosaic |
|
||||
| `Original`, no embedded preview | Full decode, downscale | Expensive — `Background` only |
|
||||
| Remote | Range-extract embedded JPEG (FR-NC-3) | 1–3 MB vs 25–100 MB |
|
||||
| Remote | Range-extract embedded JPEG (FR-NC-3) | 1–3 MB vs 25–100 MB — **measured: 262 KB of a 21.5 MB DNG, 119 ms, 1.22% of the file** |
|
||||
| Placeholder / `Offline` | None — render the offline affordance | 0 |
|
||||
|
||||
The remote path deliberately does **not** ask the Nextcloud client to hydrate the file. ARCH §9.0
|
||||
@@ -490,7 +495,73 @@ which never evict at all (FR-NC-6b).
|
||||
|
||||
---
|
||||
|
||||
## 8. What this document does not settle
|
||||
## 8. Syncing the catalog file
|
||||
|
||||
Decided 2026-08-09. **This qualifies [architecture.md §6.12](architecture.md)** — the catalog
|
||||
remains a rebuildable index, but the file itself now travels to Nextcloud. The qualification is
|
||||
worth stating precisely, because the sidecar-authoritative model is load-bearing and this is the
|
||||
one place it bends.
|
||||
|
||||
### 8.1 Why collections forced this
|
||||
|
||||
Every other thing the catalog holds has authoritative backing outside it. Ratings, labels,
|
||||
keywords, and edit graphs live in sidecars next to the images, so a rebuild recovers them.
|
||||
**Collections do not.** A manual collection is a set of images the user assembled by hand; nothing
|
||||
in the filesystem records it. Losing the catalog loses them, and no rescan brings them back.
|
||||
|
||||
So collections need to be durable across devices somehow. Syncing the catalog file is the chosen
|
||||
mechanism.
|
||||
|
||||
### 8.2 What the file sync does and does not carry
|
||||
|
||||
Only **collections and their membership** merge. The rest of a catalog describes *local* state —
|
||||
folder mtimes, cache file paths, job rows, `tier_actual` — and importing another device's version
|
||||
of those would be actively wrong. The downloaded remote is read for its collections and discarded.
|
||||
|
||||
This is what keeps §6.12 substantially intact: nothing here makes the local database authoritative
|
||||
for anything a rebuild could not recover. The catalog is still deletable. What syncs is one table
|
||||
pair that had no other home.
|
||||
|
||||
### 8.3 Two hazards the implementation must handle
|
||||
|
||||
**A WAL database is not one file.** Committed transactions can sit in `catalog.sqlite-wal` with the
|
||||
main file lagging, so copying `catalog.sqlite` alone uploads a torn snapshot — internally consistent
|
||||
as of some older point, silently missing everything since. Upload therefore runs a `TRUNCATE`
|
||||
checkpoint and then SQLite's backup API, which serialises against concurrent writers rather than
|
||||
racing them. It never copies the live file.
|
||||
|
||||
**Integer primary keys are not identities.** Two devices each allocate `collections.id = 1` for
|
||||
different collections, so a row-level merge keyed on the integer id would collide them. Collections
|
||||
therefore carry a **UUID**, and membership maps across devices by **image content hash**. The
|
||||
integer ids stay local and are never compared across catalogs.
|
||||
|
||||
### 8.4 Merge rules
|
||||
|
||||
| Concern | Rule | Why |
|
||||
|---|---|---|
|
||||
| Which collection wins | Higher `revision` — a counter bumped per local edit. `modified` only breaks an exact tie | A device with a skewed clock cannot silently overwrite real work. The same reason FR-NC-9 avoids mtime for sidecars |
|
||||
| Membership | **Set union**, not last-writer-wins | Two devices adding different images to one collection keep both. The exception — a removal racing an addition — resolves toward the addition, which is recoverable by removing it again. A lost addition is not |
|
||||
| Deletion | Tombstone (`deleted = 1`) carrying a revision | Without it, merging against a device that still holds the collection resurrects it. With a revision, deletion competes on equal footing with a rename |
|
||||
| An image the remote has and we do not | Skip the membership row | It joins on a later merge, once a scan has catalogued the file. Not an error |
|
||||
| A remote from a newer schema | Decline before attaching | Attempting it would fail mid-transaction rather than declining cleanly |
|
||||
|
||||
Merging is idempotent: running it twice reports no changes the second time. That property is tested,
|
||||
because a merge that oscillates would upload on every sync forever.
|
||||
|
||||
### 8.5 What was rejected
|
||||
|
||||
**Replace-if-newer.** The literal reading of "sync the file and take the newer one". Rejected
|
||||
because it is not a merge: whichever device syncs second loses every collection the first did not
|
||||
have. Binary SQLite files do not merge, so "newer wins" means "older is destroyed".
|
||||
|
||||
**A `collections.drsc` sidecar at the library root.** The alternative that would have kept §6.12
|
||||
untouched, merging as text the way edit sidecars do. Viable, and cheaper in machinery, but it means
|
||||
a second serialisation format and a second merge implementation for the same data. Recorded here
|
||||
because if the SQLite path proves troublesome, this is the fallback with a known shape.
|
||||
|
||||
---
|
||||
|
||||
## 9. What this document does not settle
|
||||
|
||||
- **FTS.** `Selector::Text` is a `LIKE` scan over filename and keywords. Adequate at 50k; if free
|
||||
text over description and title becomes a real workflow, an FTS5 table is the answer, and it is
|
||||
|
||||
Reference in New Issue
Block a user