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:
2026-08-09 15:20:39 +02:00
parent c8bb08e661
commit 09e3043f4c
33 changed files with 3506 additions and 155 deletions
+74 -3
View File
@@ -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