The claim that hydration-as-a-borrow makes peak disk the working set
rather than the library was so far an argument. `--example vfs_cycle`
runs it: 100 photographs of 25 MB, 90 dehydrated, a pass over all of
them through the real engine.
Peak 275 MB — the resting set plus one photograph — against 2,500 MB
had the pass simply fetched everything. Back to 250 MB afterwards, and
all ten files the user already kept still there, which is the half of
the contract that matters more.
Recorded in docs/storage.md §6.3 and ARCH §9.0a, because a bound argued
from a number nobody measured is one that gets quietly lost.
The folder connector was pointed at a Nextcloud VFS tree and got three
things wrong, the first of which loses work.
**A dehydrated sidecar read as absent.** `a.drsc` does not exist when the
client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed,
`.ok()` swallowed the `NotFound`, and the sidecar writer took that for
"there is no sidecar yet" and wrote a fresh document over the existing
one. Every edit another device had put there went with it. That function's
own doc comment calls this the exact loss the format's unknown-key
preservation exists to prevent.
**A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this
machine at 121,785 placeholders against 10,267 real files — so a folder
library on a synced tree was ~92% broken rows.
**Identity changed on hydration**, so downloading a photograph looked like
a delete and an add, orphaning its thumbnail and its face rows.
Entries now carry the photograph's own name and a `materialised` flag;
`get` on a stub returns the new `RemoteError::NotMaterialised`, which is
distinct from `NotFound` precisely because the sidecar writer must treat
them differently — it fetches the sidecar and merges, or leaves the entry
queued.
Hydration is a **borrow**. `BorrowPool` records what was on disk before it
asked, so `release_all` dehydrates only what a pass brought and leaves
what the user already had. Reference counted: the thumbnail pass and the
face pass meet on the same RAW, and without counting the first to finish
dehydrates the file the second is reading. A borrow against a plain folder
or a server does nothing, so a pass written for VFS runs everywhere.
Releasing means asking the client to dehydrate and never deleting: a
deletion inside a synced tree propagates to the server and removes the
photograph from every device.
Not a second backend — the capability is per *connection*, not per type,
since the same folder hydrates only while the client runs. The convention
arrives through a detector the registry supplies, so `dr-sync-folder`
still knows nothing about any client's protocol.
ARCH §9.0a records this as an amendment: finding 3 rejected hydration
because it costs 100× a range read, and that comparison assumed a
connector was available. A folder library has none.
The folder connector declares `LocalEtags`, which means the engine walks
the whole library on every scan with no pruning. That is the honest
capability, and the argument for it being affordable was so far an
assertion about `stat` versus `PROPFIND`.
`--example scan` runs the real path — `dr_sync::scan` over the connector,
then a ranged read of the kind the thumbnail worker makes. Read-only; it
never writes into the folder it is pointed at.
2,299 images across 233 directories in 137 ms, and 380 across 13 in
29 ms. Against 34.1 s for 17,185 RAWs over WebDAV *with* pruning
available. Recorded in docs/storage.md §5.2 and ARCH §8.4a, because a
capability trade-off argued from a number nobody measured is the kind
that gets quietly reversed later.
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
Three compromises were taken deliberately over the last few days and none of
them was written anywhere a future reader would look. So `docs/technical-debt.md`,
and two corrections to the architecture document that the Android change made
untrue the moment it landed.
**TD-1, the Android readback.** §12/6.1 says GPU results never round-trip
through the CPU, and the develop view on Android now does exactly that. That
is worth recording as a breach with reasons rather than quietly leaving a
constraint the code no longer honours — the next person to read §6.1 and then
`DevelopSession::render` would otherwise conclude one of them is a mistake.
The entry carries the device measurements that forced it, why setting
`preTransform` is not a fix available to us, and the three separate things any
one of which would remove it.
**TD-2 and TD-3**, the serial thumbnail fetch and the unbounded drain, were
found while chasing the tearing and are still outstanding. Both have a known
shape for the fix; neither is a bug, and neither should be discovered again
from scratch.
The architecture document said the app renders "through wgpu to Vulkan on both
Linux and Android", which stopped being true at 6267802, and §12/6.1 claimed a
constraint with no exceptions. Both now say what the code does and point at
the debt entry for why.
The numbers are labelled with what they are. TD-1's readback cost has *not*
been measured on the device and says so, and TD-3's figures are from a debug
build and say so — a documented measurement that quietly turns out to be the
wrong build is worse than no measurement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Targets are a 12-inch tablet and a desktop (D15). That removes most of the
navigation question rather than answering it.
`EXPANDED_MIN_WIDTH` is 820 logical pixels and a 12-inch tablet is ~1024
across in portrait, so both orientations of both targets are the expanded
class. The compact class now fires only when a desktop window is dragged
narrow — graceful degradation, not a second interface. The bottom tool strip
and the one-tool-at-a-time sheet were solving a phone, and there is no phone.
What survives is input, not size, and the architecture had already decided
it: `WidgetDemand::precise_pointing` exists for a television remote, and its
own documentation says touch is fine because hit regions grow to the
modality. Touch changes hit regions, not layout. The rules that fall out are
worth stating because they are easy to violate by accident — no hover-only
affordance and no modifier key may be the sole route to anything, since a
tablet has neither. Local masking already lost its shift-click extend for
exactly this reason.
The guaranteed-wide viewport also pays for a better answer to the extent
problem than hiding things. The complaint was never that the column is long;
it is that the histogram scrolls away from the sliders it reports on.
Collapsing shortens the scroll, pinning removes the problem, and ~260px of
fixed height is affordable on a viewport that is never under 820 wide.
docs/segmentation.md §4 priced arm B as costing a C dependency under the
NDK and treated that as most of the difference between the arms. It is not
a cost that has to be paid: `ort`'s `alternative-backend` disables its
linking entirely and `ort-tract` supplies the API from tract, which is pure
Rust. D13's "largest exception the policy would tolerate" turns out not to
be needed, and the answer generalises to the face pipeline — so D13's
runtime half is now answered and only its licensing half is open.
Three findings contradict §4 outright and are recorded as F4-F6 rather than
quietly designed around. There is no ADE20K-trained YOLO, so the shipped
vocabulary selects subjects and not stuff — "select the sky" comes from the
watershed or from nowhere. It is instance segmentation, so it partitions
nothing and two people come back as two instances. And tract cannot parse a
dynamic-shape export, which fixes the input at 640 square and makes tiling
the only route to more semantic resolution.
Arm C ships, but §8's criteria are not what decided it, and saying so
matters more than claiming the process worked. §8 asked for a two-
interaction margin over arm A on a traced corpus. That comparison was never
run: F4 and F5 changed what the arms are, and a model that recognises
subjects but has no word for sky cannot be a selection tool alone, while a
watershed cannot tell a person from the wall behind them. They stopped
being candidates and became complements.
What is *not* done is written down as plainly: the 24-image corpus is
untraced, so M1-M4 have no numbers and "this feels right" has not become
one. M5 is answered on one device only, and region ids now reach the
sidecar — so a cross-vendor divergence would mean a mask written on the
desktop meaning something else on Android. F3 stands.
Specs for the work that follows: soft delete via a MOVE that preserves the
remote id, the collection tree and smart collections, the thumbnail store,
and the derived-state folder.
Adds two design documents. ui-refinement.md names the structural gaps
between the v0.1 UI and something that feels like a photo editor.
view-composition.md proposes a view controller for the display layer,
against the 500-line run() that has become one by accretion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Correcting the previous commit: I claimed VFS placeholders could not be
downloaded. That was wrong. The desktop client exposes a socket at
$XDG_RUNTIME_DIR/Nextcloud/socket speaking newline-delimited
COMMAND:argument, and MAKE_AVAILABLE_LOCALLY:<path> does fetch the file.
Verified against client 4.0.7: a 1-byte stub became a real 2.8MB file in
2.8 seconds.
Implemented as dr-sync-nextcloud::desktop_client, deliberately optional.
Android has no desktop client, no XDG_RUNTIME_DIR socket and no
placeholders, so detect() returns None there and callers fall back to the
connector. It earns its place only because it is ~30 lines with no
dependencies: where a library already lives in a VFS folder, asking the
client to fetch beats downloading a second copy over WebDAV and leaving
the client's placeholder state inconsistent.
What this does not change: hydration is whole-file, so it suits the
original tier and never browsing. Filling a grid this way downloads the
entire library. Range extraction remains the only mechanism satisfying
FR-NC-3, and ARCH §9.0 now says so precisely.
Also fixes two real Android build failures found by cross-compiling:
- reqwest's `rustls` feature defaults to aws-lc-rs, whose aws-lc-sys
crate is C and fails under the NDK — exactly the pain D1 chose Rust
to avoid. Switched to rustls-no-provider + ring, installing the
provider in the constructor so no caller can build a client that
panics on first use.
- ring itself needs CC/AR per target; cargo-ndk sets only the linker.
Added them to the container.
87 tests passing. dr-sync-nextcloud cross-compiles for aarch64-linux-android.
Investigated using the Nextcloud desktop client's Virtual Files as a
cache instead of talking to the server directly. Measured on this
machine (client 4.0.7): the configured folder holds 121,785 placeholders
against 10,267 materialised files, including 7,037 CR2 and 9,411 DNG.
Three findings, each independently disqualifying:
- Linux VFS is *suffix* mode. A dehydrated IMG.CR2 exists only as
IMG.CR2.nextcloud holding one byte; the real name is absent.
- Reading a placeholder does not hydrate it. dd of the first 256KB
returned 1 byte, the stub was unchanged, and the real name never
appeared. There is no FUSE layer — the stub is an inert marker.
- Even with hydration the granularity is wrong: VFS has two states,
1 byte or all bytes, and the preview tier needs a ~256KB prefix of
a 27MB file. That is ~100x what FR-NC-3 requires.
Recorded as ARCH §9.0. Coexistence is still supported: dr-types now
recognises *.nextcloud stubs, and the viewer lists them as "not
downloaded" rather than as corrupt files or not at all.
So the connector talks to the server directly, as D7 specified.
Implemented: Login Flow v2, PROPFIND with oc:fileid and nc:has-preview,
ETag pruning via a Depth:0 probe, range GET with local slicing when the
server ignores the header, conditional PUT, and /core/preview with
forceIcon=false. delta() returns Unsupported and says why.
Chunked upload v2 is not implemented yet — put() rejects bodies over
5MB explicitly rather than silently truncating.
Two bugs found by testing: my hand-computed epoch in a date test was a
day out (the parser was right), and quick-xml reaches EOF on truncated
input without erroring, so unbalanced elements needed an explicit check
— a half-parsed multistatus must not look like an empty directory.
83 tests passing.
Establishes the v0.1 foundations on both platforms:
- dr-types: SourceRef (never a filesystem path — Android SAF has none),
Format, Availability, Validator with ETag quote normalisation
- dr-gpu: wgpu device, compute pass writing a storage texture, resize
- dr-ui: Slint shell with FR-UI-1 adaptive layout, computed in Rust to
avoid a binding loop
- docker/android: pinned toolchain, verified producing API 28 ARM binaries
Measured the cost of the temporary CPU readback path (dr-gpu bench):
compute is 0.06-0.28ms across sizes while readback is 0.63-7.43ms, so
readback is 90-96% of frame time and scales with area. Recorded in
ARCH §6.1 — this is why spike S1 is the priority.
Mitigations pending S1: reuse the staging buffer, apply at most one
resize per frame, and cap render resolution at 2048 on the long edge.
10 tests passing; core crates cross-compile for aarch64-linux-android.