Compare commits

..
24 Commits
Author SHA1 Message Date
dtourolle 8c3b62745a Give makensis absolute paths, and one installer to find
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h34m55s
Build and test / Layer separation (push) Successful in 1m10s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
🐳 Windows image / Build and push (push) Successful in 6s
Build and test / windows-image (push) Successful in 6s
Traceability / Requirement traces (push) Successful in 38s
Build and test / Android (aarch64) (push) Successful in 27m51s
Build and test / Windows (x86_64, cross) (push) Successful in 1h10m51s
The first CI run of the Windows leg passed every step up to packaging
and died in makensis with LicenseData: open failed
"target-windows/installer/stage\LICENSE". CI sets CARGO_TARGET_DIR to
the relative target-windows, and NSIS on a POSIX host translates the
backslash in a File path only when a leading / tells it the path is a
POSIX one; a relative name reaches it with the backslash intact.
Locally the target was always /work/…, which is why it never showed.
package.sh now resolves its directories with realpath first.

It also removes any installer already in the output directory before
writing the new one. That directory is cached between runs, so after a
version bump a glob over it finds two, and the smoke test hands Wine
both names joined by a newline as one path — which is what happened
locally the moment the version moved to 0.12.1.
2026-09-14 01:12:33 +02:00
dtourolle 8012979a1e Release 0.12.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m58s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h40m20s
Build and test / Layer separation (push) Successful in 1m5s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 3s
Build and test / windows-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 1m18s
Build and test / Android (aarch64) (push) Successful in 1h0m2s
Build and test / Windows (x86_64, cross) (push) Failing after 56m14s
2026-09-13 20:10:03 +02:00
dtourolle 5e67f026ec Merge: a catalog the server cannot damage for long, and backups on both sides
fix/corrupt-remote-catalog-deadlock. The catalog on the server had been
malformed since 7 September and every device declined to overwrite it,
so collections and people stopped syncing everywhere at once; the
damage came from two devices assembling chunks in one upload directory.
Each chunked upload now has its own directory, the server keeps three
generations of the catalog behind the current one, every push is
verified before and after, a damaged copy that arrived whole is merged
from the generation before it rather than pinned in place, and the
catalog is backed up daily as NFR-R2 always asked.
2026-09-13 20:04:55 +02:00
dtourolle eeee3d920a Back the catalog up daily, not only before migrations
NFR-R2 asks for the catalog to be backed up on a schedule and before
schema migrations. Only the second half existed: every backup on disk
was a pre-migration copy, and a library that never migrated was never
backed up at all.

A backup is now also taken at the end of a library sweep when the newest
one is more than a day old — the moment the catalog is quiet and a day's
collection and people edits have just been folded in — on its own
thread and its own connection, so the copy of a 130 MB file is not spent
on the UI. Whether one is due is read from the backup directory, not
the catalog, so the ordinary case costs nothing. An empty catalog is
skipped: there is nothing in it a rescan would not rebuild. Pruning to
KEEP_BACKUPS applies as before.
2026-09-13 19:32:05 +02:00
dtourolle f100db89ca Verify the catalog snapshot before it is sent, and after it lands
Two checks around the upload, both cheap next to what they prevent.

Before: the snapshot is quick_checked before it leaves. It is the copy
every other device merges from, and a damaged one costs each of them a
download, a failed merge and a refusal to push.

After: the staged upload's size on the server is compared to the bytes
sent before it is rotated into place. A chunked upload is assembled
server-side, and an assembly that goes wrong is a file of plausible
size no device can open — caught here, on the device that caused it,
for one listing; otherwise on every other device, after the fact. A
mismatch, or a size the server will not confirm, discards the upload
and leaves the current copy and its generations untouched.
2026-09-13 19:31:58 +02:00
dtourolle 2ce0fcc74a Keep three generations of the catalog on the server
The server held one copy of the catalog, overwritten in place on every
push. When that copy was damaged there were two answers, both bad:
refuse to touch it for ever, which is what every device did for a week,
or overwrite it with ours, which loses whatever another device had
added since — the escape hatch of the previous commit.

A push now uploads to catalog.upload.sqlite, rotates catalog.sqlite to
.1 (and .1 to .2, .2 to .3, dropping the oldest), and moves the upload
into place. Rotation is server-side renames, oldest first so that every
destination is empty when it is written to — move_to refuses to
overwrite, by design — and a failure at any step leaves a gap in the
generations and never a missing current copy. The only transfer is the
upload itself.

A damaged current copy that arrived whole now merges from the newest
readable generation before ours goes over it, which loses nothing, and
is kept as .1 by the ordinary rotation rather than by a separate 40 MB
upload. NFR-R2 asks for the catalog to be backed up; this is the half
of it that lives with the copy other devices read.
2026-09-13 19:31:58 +02:00
dtourolle 6f62ac09f8 Give every chunked upload its own directory on the server
The upload directory was named from the destination path alone, on the
reasoning that two files could then not collide. Two devices uploading
the same file could, and did: both wrote 00001…00009 into one directory,
and whichever MOVEd first assembled a mix of the two — a catalog of
exactly the right size whose pages came from two different databases.
SQLite called it malformed, every client declined to overwrite it, and
collections stopped syncing on all of them for a week. A transfer that
died on a phone's link left its chunks there for the next device to
assemble in, by the same mechanism.

The name now carries a nonce as well, so no two uploads share a
directory, and a failed transfer deletes its own directory on the way
out rather than leaving 5 MB chunks for the server to sweep eventually.
2026-09-13 19:31:58 +02:00
dtourolle c1e0f09be7 Say where the face models were looked for when they are not found
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h35m10s
Build and test / Layer separation (push) Successful in 39s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 7m11s
Build and test / windows-image (push) Successful in 7m12s
Traceability / Requirement traces (push) Successful in 58s
Build and test / Android (aarch64) (push) Successful in 24m57s
Build and test / Windows (x86_64, cross) (push) Failing after 57m30s
"The chosen detector is not installed" was the whole of what a user
saw, on a machine where the files were three directories away from
where the lookup went. The search order was in a doc comment and
nowhere a user could read it. Now a missing pair logs the detector
file it wanted and every directory it tried, which is what the first
Windows install needed and what the next misplaced download will.
2026-09-13 19:20:30 +02:00
dtourolle 38a87c0bca Put settings.json where the Android entry point said, not under /
SettingsStore::open read XDG_CONFIG_HOME and HOME itself. Neither
exists on Android, so it resolved to .config/darkroom relative to a
working directory of /, and every settings edit on the tablet failed
with "read-only file system" — the page reported the error and nothing
said why. The doc comment claimed "the same resolution SessionStore
does"; now it is, by calling the same function, which honours the
directory android_main declares and takes the platform's config
directory everywhere else. settings.json sits beside sessions.json on
every platform, as the comment always said it did.
2026-09-13 19:05:51 +02:00
dtourolle 693195fa96 Replace a damaged catalog on the server instead of pinning it there
The catalog sync refuses to upload when it cannot read the server's copy,
because the upload is a read-modify-write and writing blind would discard
another device's collections. That is the right rule for a timeout, a
dropped connection or a newer schema — the remote is fine, only our view
of it failed.

A file SQLite calls malformed is not that. No device will ever read it
again, so refusing to write over it preserves nothing — and every client
declines in turn, pinning the damaged file in place for good. Collections
and people then stop crossing between devices on all of them at once,
each logging "catalog not pushed" on every pass. This library did exactly
that from 2026-09-07, on the desktop and on a freshly installed phone
alike, while 32 collections sat undelivered.

Now a copy that arrived whole and still will not open is set aside under
a dated name and replaced by ours. Whole is checked against the size the
server advertises: a truncated download will not open either, and on a
phone that is the far likelier story, so anything short — or any size the
listing cannot confirm — is treated as the transport failure it is and
the server's copy is left alone. A placeholder's size is not trusted for
the comparison, since it means nothing.

The report says when this happened, and the log line calls it "pushed
over a damaged copy" rather than folding it into an ordinary push: it is
the one push that discarded something.
2026-09-13 19:01:00 +02:00
dtourolle 43f70c4765 Build the Windows installer in CI
The fourth leg of build-and-test.yml, in the shape of the Android one:
an image workflow that builds docker/windows and pushes it tagged by
the directory's tree id, and a job inside that image that lints the
Windows target — the only place the cfg(windows) branches are ever
compiled by CI — builds, runs the smoke tests docs/windows.md §6
specifies, packages, installs and uninstalls under Wine, and uploads
the installer. Every step was run by hand in the same container first.

The spec's open list closes with this: the four §3.2 items, the
licence page, and the leg. What remains is what Wine cannot show, and
§10 now lists it as the first real Windows run's checklist.
2026-09-12 07:34:10 +02:00
dtourolle 6609aa9acf Ship the GPL text, and show it in the installer
The repository declared GPL-3.0-or-later and carried no copy of it;
the Arch package pointed at the system's shared text and nothing else
needed one. The installer does: a licence page needs a file to show,
and the moment before installation is where the terms can still change
a decision. The standard text, at the root where every convention
looks for it, converted to CRLF at packaging time because a Windows
edit control draws a bare LF as nothing.
2026-09-12 07:34:10 +02:00
dtourolle b396096787 Open the sign-in URL on Windows
Login Flow v2 cannot complete without a browser, and the launcher had
a branch for xdg-open, one for Android's Intent, and an honest
Unsupported error for everything else — which on Windows stranded the
flow on "approve the sign-in in your browser". rundll32
url.dll,FileProtocolHandler is ShellExecute on the URL and needs no
crate; chosen over cmd /C start, whose quoting of & in a query string
is a known trap. Not verified: Wine has no browser to open.
2026-09-12 07:34:09 +02:00
dtourolle beb822dced Keep secrets in Credential Manager on Windows
The Secret Service store was keyring::Entry all the way down, and
keyring 4's v1 feature set — the one the workspace already asks for —
includes the Windows Credential Manager backend. So the Windows store
is the same implementation with its cfg widened, and the crate as a
target dependency. The one behavioural difference is that the
availability probe always succeeds there, which is correct: Credential
Manager is always present, so FR-NC-2's degraded mode does not arise.
Until now a Windows build compiled, started, and failed at sign-in
with the placeholder store's "no secret store is implemented".
2026-09-12 07:34:08 +02:00
dtourolle ef1154af94 Resolve every base directory in one place, and on Windows
Five sites each read XDG_*_HOME and fell back to $HOME/.local/… on
their own, which is fine on Linux and wrong everywhere else: Windows
sets neither variable, so every one of them degraded to a path
relative to the working directory — for a Start Menu launch,
C:\Windows\System32. The models lookup walked XDG_DATA_DIRS the same
way.

dr_plat::dirs now holds the rule per platform: XDG on Unix, the known
folders on Windows — %APPDATA% for config, which roams, and
%LOCALAPPDATA% for data and state, which do not — and the executable's
own directory as the system data dir, which is where the installer
puts the models. The Android overrides stay where they were; only the
fallback behind them moved. Both rule sets are unit-tested on either
host, and the Windows one was confirmed by running the application
under Wine: its log landed in AppData\Local\darkroom\state and nothing
was written anywhere else.
2026-09-12 07:34:05 +02:00
dtourolle 896188a489 Read the sidecars other editors write, and write them back on request
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h33m36s
Build and test / Layer separation (push) Successful in 1m2s
Traceability / Requirement traces (push) Successful in 1m25s
🐳 Android image / Build and push (push) Successful in 9s
Build and test / android-image (push) Successful in 9s
Build and test / Android (aarch64) (push) Successful in 56m59s
FR-CAT-13 asked for standard XMP and `core/dr-xmp` answered the file: it
has read and written `dc:subject`, `xmp:Rating`, `xmp:Label` and the IPTC
core since 5fa4c07, under an ownership rule that leaves everything else in
the document untouched. What nothing did was call it. No scan found an
`.xmp` beside a raw, no catalog row was filled from one, no judgement
wrote one back, and the "external modification detected, reload offered"
clause had no mechanism. A library imported from Lightroom came in and
could not go back out.

The scan collects `.xmp` beside `.drsc` from the listings it was already
paying for, and the pull reads each one whose ETag has moved. Both
namings resolve: darktable's `IMG_0001.CR3.xmp` names its file exactly,
Lightroom's `IMG_0001.xmp` names the stem, and under the stem the JPEG
beside a RAW is the same photograph and takes the same document, as
DarkRoom's own sidecar already does. Each is reconciled with the catalog
winning — keywords union, a rating or label taken only where the catalog
has none — because a standard XMP carries nothing that could say whether
its value is newer. A genuine disagreement is not resolved; it is written
to a table, and the settings page offers the sidecars' values against it.
That button is the reload the requirement asks to be offered, and the
ETag that moved is the detection it asks for: an `.xmp` edited elsewhere
is exactly a file the pull's ordinary incrementality re-reads.

Writing goes the other way behind a setting that starts off, since NFR-R4
makes writes beside somebody's originals theirs to switch on. With it on,
a judgement or a keyword rewrites the sidecar of whichever spelling
exists, or creates Lightroom's. The record is read from the catalog
whole at that moment rather than carried from the gesture, so a rating
and a keyword a second apart are two writes of one file that agree. And
the file's own title, caption, copyright and hierarchy come through the
rewrite: the catalog has no columns for them, `rewrite` replaces the
owned set wholesale, and a record that said nothing about them would have
deleted them from a Lightroom sidecar on every star.

The rating's two axes cross the format's one field both ways: a
rejection is Adobe's `-1` and stars are stars, and stars arriving on a
rejected frame lift the rejection, since the file said it was worth a
number. An unrated file says nothing and clears nothing, on the rule the
`.drsc` merge keeps. `versions.label` finally has a reader and a writer,
with the code table moved out of the query so the two cannot drift.
2026-09-12 01:08:11 +02:00
dtourolle d3b6127db6 Let a photographer name the state they liked, and go back to it or look at it
FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a
comparison against a chosen one, and neither existed. The history stack
is per sitting and forgotten with it, on purpose — the gap that mattered
was an automatically saved mis-drag with no way back, and that was closed
first. What was left was the other half: a state the photographer wants
to keep *because* it is worth keeping, which is a different thing from a
step and is not served by making the steps last longer.

A snapshot is an edit state, and an edit state is exactly what a sidecar
version stores, so it is stored as one: a `[version]` block carrying
`snapshot-of = <uuid>`. The parameters, the masks and their parts, the
repairs and the film all arrive through the blocks that already carry
them, a merge keys on the uuid as it does for any version, and a build
that predates the key reads the block as a named version and keeps it —
the right failure. Only the pointer is new. The one reader that has to
know is `default_version`, which must never answer with a snapshot: a
file whose edit is missing is not a file whose edit is one of its saved
moments. The snapshots of an edit are listed by that pointer, oldest
first, the same on every device.

Writing them back removes what this sitting deleted and puts in what it
holds, and leaves standing whatever it never saw — a snapshot the other
device took since the photograph was opened here is not this device's to
remove by not knowing about it. That is the rule the version merge
already keeps, applied one level down, and it is why the save carries
the deleted ids rather than replacing the list wholesale as the masks
are. Each is re-pointed at the uuid the save settled on, because the
default may have been fused onto its canonical identity since the
snapshot was taken.

Restoring is one history step, so undo takes it back whole, as a paste
is. Taking and deleting are not steps: they change nothing about the
photograph, and an undo that removed a snapshot would be undoing a
decision to remember. Holding the eye beside one renders the snapshot
and hands the edit straight back — the same suspension "Before" uses,
against a point the photographer chose rather than the file. Two
sessions on the same photograph get ids that cannot collide, stamped
with the second and a random word, because the merge folds equal ids
into one.
2026-09-12 01:08:10 +02:00
dtourolle 369eb8fbf0 Put the log and the crash records in one file, and show it before writing it
NFR-OPS-1 asks for a diagnostics bundle — the log, the schema version, the
GPU and driver, the app version — "with an explicit preview-and-consent
step before anything leaves the device". The log and the crash records
have existed since August; what did not exist was any way to hand them
over that was not `adb pull` and a knowledge of where the state directory
is, which on the tablet the requirement was written for is nobody.

Nothing here sends anything, and that is the design rather than a gap:
crash.rs already says why a transport built ahead of the consent is the
shape of thing that gets switched on by default. The bundle writes one
text file to a place the user can find, so that they can attach it. That
is the moment it leaves, and it is theirs. So the consent guards the
write, not a send. Preparing gathers everything into memory and shows
what would be written — each section, its size, what was taken out, and
where the file would go — and only the second press puts bytes on disk.
A user who reads the preview and presses the other button has changed
nothing anywhere. The gathered bundle is held between the presses so what
is saved is exactly what was shown, not a second gathering that differs
by whatever was logged while they were reading.

One text file rather than an archive, because a `.txt` opens wherever
the user is sitting and pastes into an issue, and because the preview
can then be the file rather than a summary of it. Every line goes
through the blunter of the two redactions on the way in, whatever the
sink already did to it: the log's own rule keeps paths, since a path
read over `adb` is context, but a file meant to be attached to a public
report by someone who may not read it first is held to the crash
record's rule instead.

The About page's graphics line gains the driver, which the requirement
names and the adapter has always reported. And docs/outstanding.md is
corrected on both OPS requirements: it said crash reporting was a
log::error! hook and NFR-OPS-1 had nothing behind it, and neither had
been true since 2026-08-30.
2026-09-12 01:08:10 +02:00
dtourolle 4574c35236 Let a part be left out of a mask without being taken out of it
A layer built from parts was missing the one control a correction most
often wants: seeing what it did. The question a subtracted gradient
raises is whether it took only the sky, and the question a stroke raises
is whether it filled the shoulder — and the only way to ask either was
to remove the part and look, which answered the question and lost the
part. The layer's own ring answers a different question, about the
adjustment, and hiding eight layers to check one correction is not an
A/B anybody performs.

So a part carries `hidden`. It is an edit and a history step, as the
layer's switch is, and it is folded into the render fingerprint because
hiding a part changes the mask as surely as removing it does. Where the
mask is built the shown parts are walked rather than the parts, which
is what makes a hidden base hand the fold to the first part that is
shown — and a revealed layer whose every part is hidden clears its slice
rather than leaving whatever the last rasterisation put there to be
read back. `covers` asks the same shown parts, so a layer whose only
adding part is hidden costs no slice at all.

In the sidecar the key is `hidden`, in the part's block or, for the
base, in the mask block — under a word that cannot be confused with the
layer's `enabled`, which has always meant the layer. Absent means shown,
so no file written before the switch existed reads any differently.

The row wears the same ring the layer does, one row down, because it is
the same question about a smaller thing.
2026-09-12 01:08:10 +02:00
dtourolle 9cc52fd72b Bind the two develop gestures that were described and not bound
FR-DEV-16's book said resetting a control and hiding a mask layer were
reachable by pointer and by finger, and stopped there. The reason was
honest: the generated rows have no focus, so "reset the focused control"
named a thing the panel could not point at. But a photographer at the
keyboard means something narrower than focus. They mean the slider they
just dragged too far, and that is a thing the panel can remember.

So the Adjustments global keeps the last control moved — two indices,
written where the panel forwards the change and cleared when the next
photograph opens, so a reset cannot reach back into the previous edit
through an index that happens to be shared. R puts it back, through the
same callback the track's double-click takes, and is silent until
something has moved.

The mask layer needs no such notion, because the panel already has a
selection: the rows the edge controls point at. H hides or shows those,
through the path the ring at the head of the row takes, so it is an edit
and a history step exactly as the ring is. A mixed selection goes to
shown, since the layer nobody can see is the one being asked about.

Both tags now carry the key, and the book says so.
2026-09-12 01:08:09 +02:00
dtourolle 2836ec2881 Build the Windows installer in a container, and run it under Wine
docs/windows.md specified it; this is §9 steps 1, 2 and 4 run, and the
report in §10. A Debian trixie image with rustup, the MinGW cross
compiler, NSIS and Wine; a build.sh in the shape of the Android one;
a package.sh that stages the executable and the seven models behind
the same LFS-pointer guard every other packager carries, then runs
makensis; and the .nsi itself — per-user, no elevation, an uninstaller
that leaves the library alone.

Measured: the executable links first time once the link flags were
right, imports only Windows system DLLs, prints its version under
Wine, and the installer installs and uninstalls silently under Wine
with the registry key and the models where §5.2 says. What Wine
cannot show is the Start Menu shortcut: CreateShortcut is IShellLink
and does nothing headless.

Four claims in the spec's first draft were wrong and are corrected in
place with the reasoning kept: the whole-archive winpthread flag
breaks the link and was never needed; build scripts need a host gcc;
bookworm's Wine lacks the bcryptprimitives.dll rustc's std imports,
so the image is trixie; and NSIS's default stub is 32-bit, so the
installer says amd64-unicode and needs no i386 Wine.
2026-09-12 00:54:11 +02:00
dtourolle fa4dca327f Give the desktop executable a version flag and a Windows identity
Three things the Windows build showed the entry point was missing, and
that a Linux build never asks for.

`--version`, answered before the logger and the crash hook install: a
binary built on a machine that cannot run the application — the Linux
CI producing the Windows executable, checked under Wine — needs an
exit that proves it starts without opening a window or touching the
user's directories. It is the smoke test in docs/windows.md §6.

A GUI-subsystem executable in release, or Windows keeps a console
window open behind the application for the life of the process. Debug
builds keep the console, which is where their log goes.

A resource block, or Explorer, the Start Menu and the taskbar show the
generic executable icon and the Details tab is empty. build.rs wraps
the PNG every other platform uses into an .ico at build time — an ICO
entry may be a PNG, so the wrapper is a 22-byte header — and hands it
to winresource with the version cargo already knows. The crate is an
unconditional build-dependency because a cfg(windows) on one is
evaluated against the host, which here is Linux; the script itself
returns before touching it on any other target.
2026-09-12 00:54:11 +02:00
dtourolle 0a2c49dd10 Guard two constants the Windows target leaves unused
secrets.rs names the keyring service and desktop_client.rs the socket
timeout, and every use of both sits under a cfg that a Windows build
does not satisfy — the placeholder secret store has nothing to file
under, and the Nextcloud client's named pipe is not opened yet. The
first cross-compile reported both as dead code, which is a failed
clippy job the moment the Windows leg runs with -D warnings. Guarded
by the same cfgs as their users, with the reason beside each.
2026-09-12 00:54:10 +02:00
dtourolle b718c70b11 Specify a Windows installer built by the Linux CI
The tree is closer to Windows than a Linux-only project usually is:
every image library, the TLS stack and the inference engine are pure
Rust, and dr-plat already keeps the Linux-only code behind cfgs with a
loud fallback where none exists for another platform. What remains is
a short list above dr-plat — five XDG path lookups, an xdg-open, the
secret store's third implementation, the models' lookup beside the
executable — and none of it touches core, which is the NFR-PORT-3 test
this would be the first real run of.

docs/windows.md decides the GNU target over MSVC-via-xwin, Vulkan only
as on every other platform, a per-user NSIS installer that leaves the
library alone on uninstall, and a CI leg in the shape of the Android
one. It is explicit about what a runner with no Windows can verify —
that it links, is PE32+, starts under Wine and installs under Wine —
and what it cannot, which is everything involving a real GPU driver.
Three FR-PLAT-WIN requirements and a channel row record the decisions;
the ordering puts a first cross-compile on the developer machine before
any container exists, because the list of cfg gaps is a reading of the
source and the compiler's list will be longer.
2026-09-11 23:30:16 +02:00
64 changed files with 5912 additions and 359 deletions
+106
View File
@@ -365,6 +365,112 @@ jobs:
path: target-android/apk/darkroom.apk
if-no-files-found: error
windows-image:
uses: ./.gitea/workflows/windows-image.yml
# TRACES: FR-PLAT-WIN-3
# The Windows executable and its installer, cross-built from Linux
# (docs/windows.md §7). No Windows machine anywhere in this job: what it
# can prove is that the binary links, is a Windows executable with no
# MinGW runtime imports, starts under Wine, and that the installer installs
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a
# render, the secret store — is a release step on a real machine (§6).
windows:
runs-on: linux/amd64
name: Windows (x86_64, cross)
needs: windows-image
container:
image: gitea.tourolle.paris/dtourolle/darkroom-windows:latest
env:
CARGO_INCREMENTAL: 0
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_TARGET_DIR: target-windows
# Wine keeps its prefix under $HOME, which the image points at a
# directory that does not exist in a fresh container.
HOME: /tmp/home
steps:
- name: Checkout
uses: actions/checkout@v4
# Same step as the desktop leg: the models are LFS objects and the
# packager refuses pointers.
- name: Fetch the models
env:
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
run: |
set -e
git lfs install --local
git config --local --get-regexp '^http\..*extraheader$' \
| cut -d' ' -f1 | sort -u \
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull
ls -l models/face models/scene
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
/opt/cargo/registry
target-windows
key: windows-${{ hashFiles('**/Cargo.lock') }}
# The cfg(windows) branches are linted here and nowhere else: the
# desktop leg's clippy never compiles them.
- name: Clippy for the target
run: cargo clippy --release --target x86_64-pc-windows-gnu -p darkroom-desktop -- -D warnings
- name: Build
run: cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
- name: Smoke-test the executable
run: |
set -e
mkdir -p "$HOME"
EXE=target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe
file "$EXE"
file "$EXE" | grep -q 'PE32+' || { echo "FAIL: not a PE32+ executable"; exit 1; }
file "$EXE" | grep -q '(GUI)' || { echo "FAIL: not a GUI-subsystem executable"; exit 1; }
if x86_64-w64-mingw32-objdump -p "$EXE" | grep -iE 'libwinpthread|libgcc|libstdc'; then
echo "FAIL: the executable imports a MinGW runtime DLL"
exit 1
fi
x86_64-w64-mingw32-objdump -p "$EXE" | grep 'DLL Name' | sort -u
wineboot --init >/dev/null 2>&1 || true
OUT=$(wine "$EXE" --version 2>/dev/null)
echo "wine: $OUT"
echo "$OUT" | grep -q '^darkroom-desktop ' || { echo "FAIL: --version did not answer under Wine"; exit 1; }
- name: Package the installer
run: bash docker/windows/package.sh
- name: Smoke-test the installer
run: |
set -e
SETUP=$(ls target-windows/installer/DarkRoom-*-x86_64-setup.exe)
file "$SETUP" | grep -q 'PE32+' || { echo "FAIL: the installer is not 64-bit"; exit 1; }
wine "$SETUP" /S 2>/dev/null
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
ls "$INST"
[ "$(ls "$INST/models" | wc -l)" = 7 ] || { echo "FAIL: expected 7 model files"; exit 1; }
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|| { echo "FAIL: the installed executable does not run"; exit 1; }
wine "$INST/uninstall.exe" /S 2>/dev/null
sleep 3
[ ! -e "$INST" ] || { echo "FAIL: uninstall left $INST behind"; ls -R "$INST"; exit 1; }
echo "OK: installed and uninstalled under Wine"
- name: Upload the installer
uses: actions/upload-artifact@v3
with:
name: darkroom-windows-x86_64-setup
path: target-windows/installer/DarkRoom-*-x86_64-setup.exe
if-no-files-found: error
layering:
runs-on: linux/amd64
name: Layer separation
+170
View File
@@ -0,0 +1,170 @@
name: '🐳 Windows image'
# Builds and pushes gitea.tourolle.paris/dtourolle/darkroom-windows, the job
# container for the Windows leg of build-and-test.yml.
#
# The same shape as android-image.yml, for the same reason that one exists:
# an image that lives only on a developer's laptop is a job that dies at
# `docker pull`. Built from docker/windows, tagged by that directory's tree
# id, skipped when the registry already has it.
#
# Called by build-and-test.yml on every push, and runnable by hand via
# workflow_dispatch. It is cheap when nothing changed — see the guard below.
on:
workflow_call:
inputs:
force:
description: 'Rebuild even if the registry already has this image ("true"/"false")'
type: string
default: 'false'
workflow_dispatch:
inputs:
force:
description: 'Rebuild even if the registry already has this image ("true"/"false")'
type: string
default: 'false'
# Gitea's act_runner mangles boolean workflow inputs passed through an
# expression — they arrive as false regardless of what was sent. Every input
# here is a string compared with == 'true', as in KPN's docker.yaml.
env:
IMAGE: gitea.tourolle.paris/dtourolle/darkroom-windows
jobs:
build:
runs-on: linux/amd64
name: Build and push
# Deliberately NOT in a container: this job needs the host Docker daemon to
# build an image, and the host's cached ~/.docker/config.json to push it.
# That is also why there is no `docker login` step — the runner host was
# authenticated to the registry during setup.
steps:
# The host has no Node, so the JS-based actions/checkout cannot run here.
# A minimal shallow fetch with plain git gets the same tree.
- name: Checkout
run: |
set -e
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: basic $(printf '%s' '${{ github.actor }}:${{ github.token }}' | base64 -w0)" \
fetch --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
# The image is tagged by the content of docker/windows, not by the commit
# that happened to touch it. `git rev-parse HEAD:<dir>` is the tree object
# id — it changes when and only when a file in that directory changes, so
# an unrelated push reuses the existing image and a Dockerfile edit can
# never silently keep serving a stale `latest`.
#
# Using the commit sha instead would rebuild 2.5 GB on every push; using a
# paths-filter action would need a container that has Node, and the only
# one this repo would reach for is the very image being built.
- name: Resolve image tag
id: tag
run: |
set -e
TREE=$(git rev-parse HEAD:docker/windows)
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
echo "docker/windows tree: $TREE"
# Skip the build when the registry already holds this exact content. This
# is what keeps the job a few seconds long on a normal push, and what
# makes it self-healing: if the tag is missing for any reason, including
# the image having never been pushed at all, it gets built here.
#
# The probe is curl against the registry API, NOT `docker manifest
# inspect`. The latter exits 1 on this registry even for tags that are
# demonstrably present — jellytau-builder:latest answers HTTP 200 to the
# API while `docker manifest inspect` reports "manifest unknown" for it.
# Trusting that would have rebuilt 7 GB on every single push.
#
# A HEAD request also gives the digest for free, which is how the repoint
# decision below is made without pulling any layers.
- name: Query registry
id: check
env:
# The runner's own credentials, so this does not depend on how the
# host's ~/.docker/config.json happens to be set up.
REG_USER: ${{ github.actor }}
REG_PASS: ${{ github.token }}
TREE: ${{ steps.tag.outputs.tree }}
run: |
set -eu
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
API="https://gitea.tourolle.paris/v2/dtourolle/darkroom-windows/manifests"
# Prints "<http-status> <digest-or-empty>" for a tag.
probe() {
curl -sI -u "$REG_USER:$REG_PASS" -H "Accept: $ACCEPT" "$API/$1" \
| tr -d '\r' \
| awk 'BEGIN{s="000";d=""} /^HTTP/{s=$2} tolower($1)=="docker-content-digest:"{d=$2} END{print s, d}'
}
read -r TREE_STATUS TREE_DIGEST <<EOF
$(probe "$TREE")
EOF
read -r LATEST_STATUS LATEST_DIGEST <<EOF
$(probe latest)
EOF
echo "tag $TREE -> HTTP $TREE_STATUS ${TREE_DIGEST:-(no digest)}"
echo "tag latest -> HTTP $LATEST_STATUS ${LATEST_DIGEST:-(no digest)}"
# Build unless the registry definitively confirms this content is
# already there. An auth failure or an unreachable registry lands
# here too, and rebuilding needlessly is the safe direction to fail —
# skipping a build that was needed is what breaks the Windows job.
if [ "${{ inputs.force }}" = "true" ]; then
echo "forced rebuild requested"
echo "build=true" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
elif [ "$TREE_STATUS" != "200" ]; then
echo "registry does not have this content — building"
echo "build=true" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
elif [ -n "$TREE_DIGEST" ] && [ "$TREE_DIGEST" = "$LATEST_DIGEST" ]; then
echo "registry is already correct — nothing to do"
echo "build=false" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
else
echo "content is present but latest points elsewhere — repointing"
echo "build=false" >> "$GITHUB_OUTPUT"
echo "repoint=true" >> "$GITHUB_OUTPUT"
fi
# Context is docker/windows, matching the README's build command. The
# Dockerfile COPYs nothing from the repo, so it needs no wider context —
# and a narrow context keeps the daemon from tarring up the whole tree,
# target/ included.
- name: Build
if: ${{ steps.check.outputs.build == 'true' }}
run: |
set -e
docker build \
-t "$IMAGE:${{ steps.tag.outputs.tree }}" \
-t "$IMAGE:latest" \
docker/windows
# Both tags are pushed: the tree tag is what the guard above looks for on
# the next run, and `latest` is what build-and-test.yml pulls.
- name: Push
if: ${{ steps.check.outputs.build == 'true' }}
run: |
set -e
docker push "$IMAGE:${{ steps.tag.outputs.tree }}"
docker push "$IMAGE:latest"
# A cache hit on the tree tag says nothing about where `latest` points — a
# reverted Dockerfile or a build from another branch can leave it on
# different content. This runs only when the digests above actually
# disagree, so the common case costs nothing; the layers are already in
# the registry, so the push that follows uploads a manifest, not 2.5 GB.
- name: Repoint latest
if: ${{ steps.check.outputs.repoint == 'true' }}
run: |
set -e
docker pull "$IMAGE:${{ steps.tag.outputs.tree }}"
docker tag "$IMAGE:${{ steps.tag.outputs.tree }}" "$IMAGE:latest"
docker push "$IMAGE:latest"
Generated
+35 -23
View File
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"android_logger",
"dr-plat",
@@ -1234,13 +1234,14 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"anyhow",
"dr-plat",
"dr-ui",
"env_logger",
"log",
"winresource",
]
[[package]]
@@ -1407,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-bench"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"anyhow",
"dr-catalog",
@@ -1424,7 +1425,7 @@ dependencies = [
[[package]]
name = "dr-catalog"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"dr-face",
"dr-plat",
@@ -1439,7 +1440,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"dr-types",
"env_logger",
@@ -1453,7 +1454,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1471,7 +1472,7 @@ dependencies = [
[[package]]
name = "dr-face"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"env_logger",
"log",
@@ -1484,7 +1485,7 @@ dependencies = [
[[package]]
name = "dr-film"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"log",
"serde",
@@ -1493,7 +1494,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1510,7 +1511,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"dr-plat",
"dr-types",
@@ -1522,7 +1523,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"lensfun",
"log",
@@ -1530,7 +1531,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"dr-types",
"log",
@@ -1539,7 +1540,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1555,7 +1556,7 @@ dependencies = [
[[package]]
name = "dr-preset-xmp"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"dr-pipeline",
"log",
@@ -1565,7 +1566,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"env_logger",
"log",
@@ -1578,7 +1579,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"async-trait",
"dr-plat",
@@ -1592,7 +1593,7 @@ dependencies = [
[[package]]
name = "dr-sync-folder"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"async-trait",
"dr-sync",
@@ -1604,7 +1605,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"async-trait",
"dr-decode",
@@ -1626,7 +1627,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1638,7 +1639,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"serde",
"serde_json",
@@ -1647,7 +1648,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"anyhow",
"async-trait",
@@ -1668,6 +1669,7 @@ dependencies = [
"dr-sync-nextcloud",
"dr-thumbs",
"dr-types",
"dr-xmp",
"env_logger",
"jni 0.22.4",
"log",
@@ -1686,7 +1688,7 @@ dependencies = [
[[package]]
name = "dr-xmp"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"dr-types",
"log",
@@ -6987,7 +6989,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.12.0"
version = "0.12.1"
dependencies = [
"anyhow",
"serde",
@@ -8387,6 +8389,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml 1.1.4+spec-1.1.0",
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+1 -1
View File
@@ -27,7 +27,7 @@ members = [
]
[workspace.package]
version = "0.12.0"
version = "0.12.1"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
+232
View File
@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
+8
View File
@@ -15,5 +15,13 @@ anyhow.workspace = true
env_logger.workspace = true
log.workspace = true
# The Windows resource block — icon and version — compiled in by build.rs.
# Unconditional rather than under `[target.'cfg(windows)']`, because a cfg on
# a build-dependency is evaluated against the *host* — the machine running
# the build script — and this is built for Windows from Linux. The script
# itself returns before touching the crate on every other target.
[build-dependencies]
winresource = "0.1"
[features]
default = []
+58
View File
@@ -0,0 +1,58 @@
//! TRACES: FR-PLAT-WIN-2
//! The Windows resource block: icon and version, compiled into the executable.
//!
//! Windows takes an application's icon and its "Details" tab from a resource
//! inside the `.exe`, not from a `.desktop` file, so without this the installed
//! program shows the generic executable icon in Explorer, the Start Menu and
//! the taskbar, and reports no version. Nothing here runs for any other
//! target: the whole body is behind the target-OS check, and the crate that
//! does the work is a build-dependency only.
//!
//! The icon is the same PNG every other platform uses, wrapped into an `.ico`
//! in `OUT_DIR` rather than committed: an ICO entry may *be* a PNG (Vista and
//! later read them directly), so the wrapper is a 22-byte header and the
//! file's bytes, and a generated binary stays out of the tree.
use std::io::Write as _;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let png = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../ui/dr-ui/ui/app-icon.png");
println!("cargo:rerun-if-changed={}", png.display());
let bytes = std::fs::read(&png).expect("read app-icon.png");
let ico = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("darkroom.ico");
write_png_ico(&ico, &bytes, 256).expect("write darkroom.ico");
let mut res = winresource::WindowsResource::new();
res.set_icon(ico.to_str().unwrap());
res.set("ProductName", "DarkRoom");
res.set("FileDescription", "DarkRoom");
res.set("LegalCopyright", "GPL-3.0-or-later");
// Cross-compiling: `winresource` looks for a `windres` for the target and
// the Windows image names it explicitly, for the same reason the Android
// image names its linkers.
if let Ok(windres) = std::env::var("WINDRES") {
res.set_windres_path(&windres);
}
res.compile().expect("compile the Windows resource block");
}
/// One PNG image as an `.ico`. `edge` is the PNG's width and height; 256 is
/// written as 0 per the format.
fn write_png_ico(path: &std::path::Path, png: &[u8], edge: u32) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
let dim = if edge >= 256 { 0u8 } else { edge as u8 };
// ICONDIR: reserved, type 1 (icon), one image.
f.write_all(&[0, 0, 1, 0, 1, 0])?;
// ICONDIRENTRY: width, height, palette 0, reserved, planes 1, bpp 32,
// byte length, offset (6 + 16).
f.write_all(&[dim, dim, 0, 0, 1, 0, 32, 0])?;
f.write_all(&(png.len() as u32).to_le_bytes())?;
f.write_all(&22u32.to_le_bytes())?;
f.write_all(png)
}
+20
View File
@@ -1,12 +1,32 @@
//! DarkRoom desktop entry point.
//!
//! darkroom-desktop <file-or-directory>...
//! darkroom-desktop --version
// TRACES: FR-PLAT-WIN-2
// A GUI-subsystem executable, or Windows opens a console window behind the
// application for the life of the process. Release only: the console is where
// the log goes when there is no file, and a debug build is run from one.
// `--version` still prints under this — stdout is simply not attached when
// launched from Explorer, which is not where anyone asks for a version.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
use std::path::PathBuf;
use dr_plat::diagnostics::Installed;
fn main() -> anyhow::Result<()> {
// TRACES: FR-PLAT-WIN-3
// Before the logger, the crash hook and everything else: this exists so a
// build made on a machine that cannot run the application — the Linux CI
// producing the Windows binary, checked under Wine — has an exit that
// proves the executable starts without opening a window or touching the
// user's directories (docs/windows.md §6).
if std::env::args().nth(1).as_deref() == Some("--version") {
println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
// Built rather than `init`ed, so the same logger can be handed to the
// diagnostics tee: `env_logger` keeps writing to stderr exactly as before,
// and every record it accepts is also appended to the on-disk log
+1 -7
View File
@@ -301,13 +301,7 @@ fn like_prefix(path: &str) -> String {
}
fn label_code(l: ColourLabel) -> i64 {
match l {
ColourLabel::Red => 1,
ColourLabel::Yellow => 2,
ColourLabel::Green => 3,
ColourLabel::Blue => 4,
ColourLabel::Purple => 5,
}
crate::rating::label_code(l)
}
fn flag_code(f: FlagState) -> i64 {
+30 -1
View File
@@ -30,7 +30,7 @@
use rusqlite::{Connection, OptionalExtension};
use dr_types::{FlagState, ImageId};
use dr_types::{ColourLabel, FlagState, ImageId};
use crate::error::CatalogError;
@@ -275,6 +275,35 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
/// TRACES: FR-CAT-13
/// How `versions.label` encodes a colour label, and back.
///
/// One place for both directions, so a label written by the XMP pull and a
/// label queried by the selector cannot drift apart: the query used to hold
/// its own copy of the forward mapping and nothing held the reverse.
pub fn label_code(l: ColourLabel) -> i64 {
match l {
ColourLabel::Red => 1,
ColourLabel::Yellow => 2,
ColourLabel::Green => 3,
ColourLabel::Blue => 4,
ColourLabel::Purple => 5,
}
}
/// The colour a `versions.label` value names, or `None` for NULL and for a
/// code this build does not know.
pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
Some(match code? {
1 => ColourLabel::Red,
2 => ColourLabel::Yellow,
3 => ColourLabel::Green,
4 => ColourLabel::Blue,
5 => ColourLabel::Purple,
_ => return None,
})
}
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
let existing: Option<i64> = conn
.query_row(
+91
View File
@@ -198,6 +198,54 @@ pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(),
Ok(())
}
/// How long a catalog may go without a backup before the next opportunity
/// takes one.
///
/// A day. The catalog is an index, so what a backup protects is the day's
/// worth of collection and people edits the sidecars do not hold — and a
/// second copy of a 130 MB file per launch would be a cost with nothing to
/// show for it when the user launches four times in an afternoon.
pub const BACKUP_EVERY: i64 = 24 * 60 * 60;
/// Whether [`BACKUP_EVERY`] has passed since the newest backup, or there is
/// none.
///
/// Read from the filenames, like [`backups`], so a restored or copied backup
/// directory answers the same way it did on the machine it came from.
pub fn backup_due(catalog: &Path) -> bool {
match backups(catalog).first() {
Some(newest) => now() - newest.taken_at >= BACKUP_EVERY,
None => true,
}
}
/// TRACES: NFR-R2
/// Take the scheduled backup, if one is due. Returns the file written, or
/// `None` when the newest is recent enough.
///
/// The scheduled half of NFR-R2 — the migration half is
/// [`backup_before_migration`]. "On a schedule" for an application that runs
/// when the user opens it means "at the next chance after a day has passed",
/// and the chance the caller picks is the end of a library sweep: the
/// catalog is quiet, the work is already off the UI thread, and it is the
/// moment a day's edits have just been consolidated.
///
/// A brand-new catalog with no images is not backed up: there is nothing in
/// it yet that a rescan would not rebuild, and the first backup would only be
/// a copy of an empty schema.
pub fn backup_if_due(conn: &Connection, catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
if !backup_due(catalog) {
return Ok(None);
}
let images: i64 = conn.query_row("SELECT count(*) FROM images", [], |r| r.get(0))?;
if images == 0 {
return Ok(None);
}
let path = backup(conn, catalog)?;
log::info!("scheduled backup of the catalog to {}", path.display());
Ok(Some(path))
}
/// The backups available for `catalog`, newest first.
///
/// Never fails: an unreadable or absent backup directory means there are no
@@ -386,6 +434,49 @@ mod tests {
base
}
#[test]
fn a_scheduled_backup_is_taken_once_a_day_and_not_more() {
let dir = tempdir("scheduled");
let path = dir.join("catalog.sqlite");
fixture(&path, 3);
let cat = Catalog::open(&path).unwrap();
// Nothing yet: due.
assert!(backup_due(&path));
let first = backup_if_due(cat.connection(), &path).unwrap();
assert!(first.is_some(), "the first opportunity takes one");
// Taken just now: not due, and a second call does nothing.
assert!(!backup_due(&path));
assert_eq!(backup_if_due(cat.connection(), &path).unwrap(), None);
assert_eq!(backups(&path).len(), 1);
// Age the one backup past the interval by renaming it, since the
// timestamp is read from the name. Now it is due again.
let old = first.unwrap();
let aged = old
.parent()
.unwrap()
.join(format!("catalog-{}.sqlite", now() - BACKUP_EVERY - 1));
std::fs::rename(&old, &aged).unwrap();
assert!(backup_due(&path));
assert!(backup_if_due(cat.connection(), &path).unwrap().is_some());
assert_eq!(backups(&path).len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_empty_catalog_is_not_worth_backing_up() {
let dir = tempdir("empty");
let path = dir.join("catalog.sqlite");
let cat = Catalog::open(&path).unwrap();
assert!(backup_due(&path), "due in principle");
assert_eq!(backup_if_due(cat.connection(), &path).unwrap(), None);
assert!(backups(&path).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// A catalog on disk with enough rows to span several pages, closed.
///
/// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until
+38 -1
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError;
/// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 14;
pub const SCHEMA_VERSION: i64 = 15;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
@@ -136,6 +136,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?;
}
if from < 15 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V15)?;
tx.pragma_update(None, "user_version", 15)?;
tx.commit()?;
}
Ok(from)
}
@@ -642,6 +649,36 @@ DELETE FROM face_index
AND f.model_id = face_index.model_id);
"#;
const V15: &str = r#"
-- TRACES: FR-CAT-13
-- Where a standard XMP sidecar and the catalog disagree.
--
-- An `.xmp` beside a photograph is read on the same pull as DarkRoom's own
-- sidecar, and reconciled field by field (`dr_xmp::reconcile`): keywords
-- union, and a rating, label or caption is taken only where the catalog holds
-- none. That rule is the safe one and it is not always the right one -- a
-- rating changed in Lightroom after it was changed here is a genuine
-- disagreement, and a standard XMP carries no revision to settle it by. So
-- the disagreement is written here instead of being resolved, and the
-- requirement's "a metadata reload offered" is a row in this table with a
-- button in front of it: the reload re-reads the file with the sidecar
-- winning, and deletes the row.
--
-- Keyed on the sidecar's path like `sidecars` is, and for the same reason: a
-- path is what the scan reports, what a fetch addresses, and what the ETag
-- that noticed the change belongs to. `fields` is the disagreeing fields as
-- `dr_xmp` names them, space-separated, for the line the settings page shows.
--
-- Rebuildable: the next pull that sees a changed ETag writes the row again.
CREATE TABLE IF NOT EXISTS xmp_conflicts (
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
fields TEXT NOT NULL,
seen_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(root_id, path)
);
"#;
const V9: &str = r#"
-- TRACES: FR-CULL-8
-- A record that face detection has *run* on an image, distinct from what it
+22
View File
@@ -54,9 +54,31 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
let out = copy_to(conn, dest)?;
strip_face_crops(&out)?;
verify_snapshot(&out)?;
Ok(())
}
/// TRACES: NFR-R2
/// Refuse to hand over a snapshot that will not pass `quick_check`.
///
/// The upload is the copy every other device merges from, and a damaged one
/// costs far more than the check: each device downloads it, fails, and — for
/// a week, once — declines to push over it. `quick_check` reads every page
/// but skips index verification, which is the affordable version of "is this
/// a database" on a 40 MB file that has just been written and is still in the
/// page cache. A failure here is [`CatalogError::Corrupt`], the same thing a
/// receiving device would have said, so the sync reports it the same way.
fn verify_snapshot(snapshot: &Connection) -> Result<(), CatalogError> {
let verdict: String = snapshot.query_row("PRAGMA quick_check", [], |r| r.get(0))?;
if verdict == "ok" {
Ok(())
} else {
Err(CatalogError::Corrupt {
detail: format!("the snapshot for upload failed quick_check: {verdict}"),
})
}
}
/// Checkpoint, then copy the whole database to `dest`, and hand back the
/// connection to the copy.
///
+14
View File
@@ -332,6 +332,20 @@ impl GpuContext {
pub fn backend(&self) -> wgpu::Backend {
self.adapter_info.backend
}
/// TRACES: NFR-OPS-1
/// The driver, as the adapter reported it, for a diagnostics bundle.
/// Name and version in one string because wgpu splits them by backend
/// and neither half means much without the other.
pub fn driver(&self) -> String {
let info = &self.adapter_info;
match (info.driver.is_empty(), info.driver_info.is_empty()) {
(true, true) => "unknown driver".to_string(),
(false, true) => info.driver.clone(),
(true, false) => info.driver_info.clone(),
(false, false) => format!("{} {}", info.driver, info.driver_info),
}
}
}
#[repr(C)]
+40 -2
View File
@@ -758,12 +758,24 @@ impl MaskPass {
// is done where it is read back rather than where it is drawn —
// a brush deposits dabs and cannot know what the rest of the
// frame is. See `fs_combine`.
let direct = layer.parts().len() == 1 && !layer.base().invert;
// TRACES: FR-DEV-19a
// The shown parts, not the parts: a hidden one is skipped here
// and nowhere else, and the first *shown* part is the one that
// opens the fold. Which can leave nothing — a revealed layer with
// every part hidden — and that clears the slice rather than
// leaving whatever the last rasterisation put there to be read
// back as this mask.
let shown: Vec<&dr_pipeline::mask::MaskPart> = layer.shown_parts().collect();
if shown.is_empty() {
self.clear_slice(&mut encoder, slot as u32);
continue;
}
let direct = shown.len() == 1 && !shown[0].invert;
if !direct {
self.ensure_scratch(width, height)?;
}
for (index, part) in layer.parts().iter().enumerate() {
for (index, part) in shown.iter().copied().enumerate() {
let base = index == 0;
let field = match (&part.source, labels) {
(MaskSource::Regions { .. }, None) => {
@@ -1369,6 +1381,32 @@ impl MaskPass {
pass.draw(0..3, 0..1);
}
/// Leave a layer's slice covering nothing.
///
/// A pass that clears and draws nothing, for the one case where a layer
/// reaches the array with no part to draw: every part hidden while the
/// layer is being revealed. The slice has to be written, because the
/// shader reads it whatever this function did.
fn clear_slice(&self, encoder: &mut wgpu::CommandEncoder, slot: u32) {
let target = self.slice_view(slot);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-clear-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
}
/// The texture a part is drawn in before it is joined.
///
/// Allocated on the first mask that has more than one part and kept at the
+2
View File
@@ -1034,6 +1034,8 @@ impl EditGraph {
colour = hash_bytes(colour, part.join.name().as_bytes());
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
colour = mix(colour, u64::from(part.invert));
// Hiding a part changes the fold as surely as removing it.
colour = mix(colour, u64::from(part.hidden));
colour = hash_bytes(colour, part.falloff.name().as_bytes());
for v in [part.feather, part.morph_radius] {
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
+59 -2
View File
@@ -984,6 +984,20 @@ pub struct MaskPart {
/// subtracted gradient keeps everything to one side of a line, where
/// inverting the layer keeps everything the layer did not select.
pub invert: bool,
/// TRACES: FR-DEV-19a
/// Left out of the build without being deleted.
///
/// The A/B a *part* wants is different from the layer's: the question is
/// not "what does this adjustment do" but "what did this correction do to
/// the selection" — whether the stroke that was meant to fill in a
/// shoulder did, or the subtracted gradient took the sky it was aimed at
/// and nothing else. Removing the part answers that and loses it; this
/// answers it and keeps it.
///
/// Hidden parts are skipped where the mask is built, so a hidden base
/// leaves the first shown part to open the fold — and a mask whose every
/// adding part is hidden covers nothing, exactly as if they were gone.
pub hidden: bool,
/// Half-width of the edge transition, as a fraction of the frame's
/// **shorter edge**.
@@ -1066,6 +1080,7 @@ impl MaskPart {
join,
source,
invert: false,
hidden: false,
// A small default rather than zero. A watershed boundary is exact
// to the pixel, and an adjustment that stops dead on one looks
// pasted on — the first thing anyone would reach for, so it is
@@ -1280,6 +1295,7 @@ impl PartialEq for MaskPart {
&& self.join == other.join
&& self.source == other.source
&& self.invert == other.invert
&& self.hidden == other.hidden
&& self.feather == other.feather
&& self.falloff == other.falloff
&& self.morphology == other.morphology
@@ -1555,12 +1571,23 @@ impl MaskLayer {
/// whose other parts all subtract covers nothing however many of them
/// there are, and rasterising it would cost a slice to draw empty.
fn covers(&self) -> bool {
self.parts
.iter()
// A hidden part is not in the build, whichever way it joins — and a
// hidden base hands its role to the first part that is shown, which
// is why "adds" is asked of the shown parts rather than of index 0.
self.shown_parts()
.enumerate()
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
}
/// TRACES: FR-DEV-19a
/// The parts that take part in the build, in order — every part that is
/// not [`MaskPart::hidden`]. The first of these opens the fold whatever
/// its index in [`Self::parts`], so a renderer walks this rather than
/// filtering for itself and getting the "first" wrong.
pub fn shown_parts(&self) -> impl Iterator<Item = &MaskPart> {
self.parts.iter().filter(|p| !p.hidden)
}
/// The operations in this layer's chain that reach the shader.
///
/// Neighbourhood operations are excluded, and not as an oversight. A
@@ -2883,6 +2910,36 @@ mod tests {
// Parts
// -----------------------------------------------------------------------
/// TRACES: FR-DEV-19a
/// A hidden part is out of the build and nothing else about it moves: it
/// is still there to be put back, and the parts that remain shown are
/// what the mask is made of — including which of them is first.
#[test]
fn a_hidden_part_leaves_the_build_and_stays_in_the_layer() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.push_part(MaskPart::new("p2", Join::Union, MaskSource::highlights()));
layer.push_part(MaskPart::painted("p3", Join::Subtract));
layer.set_param("exposure", ParamId("exposure"), 0.5);
assert_eq!(layer.shown_parts().count(), 3);
assert!(layer.is_active(), "the range adds, so the mask covers");
layer.part_mut(1).expect("p2").hidden = true;
assert_eq!(layer.parts().len(), 3, "hidden is not removed");
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
assert_eq!(shown, ["p1", "p3"]);
assert!(
!layer.is_active(),
"with the range out, an unpainted base and a subtraction cover nothing"
);
layer.part_mut(1).expect("p2").hidden = false;
layer.base_mut().hidden = true;
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
assert_eq!(shown, ["p2", "p3"], "the range now opens the fold");
assert!(layer.is_active(), "and it still covers the picture");
}
/// The invariant everything else leans on: a layer always has a selection
/// to be. Removing the base is removing the layer, and the panel must not
/// be able to reach a state where a mask exists with nothing in it.
+175 -1
View File
@@ -126,6 +126,23 @@ pub struct Version {
pub uuid: String,
pub name: String,
pub is_default: bool,
/// TRACES: FR-DEV-5
/// The version this is a named snapshot of, if it is one.
///
/// A snapshot *is* an edit state, which is exactly what a version stores,
/// so it is stored as one: the parameters, the masks and their parts, the
/// repairs and the film all arrive through the blocks that already carry
/// them, and a merge keys on the uuid as it does for any other version.
/// What sets a snapshot apart is only this pointer — it belongs to
/// another version's history rather than standing beside it as a variant
/// (FR-CAT-12), so a reader listing what a photograph *is* skips it, and
/// a reader listing what one edit *was* finds it here.
///
/// Written as `snapshot-of`. A build that predates the key reads the
/// block as an ordinary named version and keeps it, which is the right
/// failure: nothing is lost, and the newer build finds it as a snapshot
/// again.
pub snapshot_of: Option<String>,
/// Monotonic per-edit counter (FR-NC-8).
///
/// The primary merge discriminator, ahead of [`Self::modified`]: a device
@@ -222,6 +239,7 @@ impl Version {
uuid: uuid.into(),
name: name.into(),
is_default: false,
snapshot_of: None,
revision: 1,
device: String::new(),
modified: 0,
@@ -267,6 +285,12 @@ impl Version {
})
}
/// TRACES: FR-DEV-5
/// Whether this version is a named snapshot of another one.
pub fn is_snapshot(&self) -> bool {
self.snapshot_of.is_some()
}
/// Record `graph` into this version, bumping the revision.
///
/// The revision bump is what makes this the write path rather than a
@@ -645,7 +669,52 @@ impl Sidecar {
.values()
.filter(|v| v.is_default)
.max_by_key(|v| (v.revision, v.modified))
.or_else(|| self.versions.values().next())
// Never a snapshot: a file with no default and a snapshot in it
// is a file whose edit is *missing*, and answering with a saved
// state of it would open the photograph at a moment the
// photographer deliberately stepped away from.
.or_else(|| self.versions.values().find(|v| !v.is_snapshot()))
}
/// TRACES: FR-DEV-5
/// The named snapshots of one version, oldest first.
///
/// Taken time is `modified`, so the order is the order they were taken in
/// whichever device took them; the uuid breaks a tie so the list reads
/// the same on every device.
pub fn snapshots_of(&self, uuid: &str) -> Vec<&Version> {
let mut out: Vec<&Version> = self
.versions
.values()
.filter(|v| v.snapshot_of.as_deref() == Some(uuid))
.collect();
out.sort_by(|a, b| (a.modified, &a.uuid).cmp(&(b.modified, &b.uuid)));
out
}
/// TRACES: FR-DEV-5 | FR-NC-9
/// Write a session's snapshots of `uuid` into the file.
///
/// `removed` are the ones the session deleted, taken out by id;
/// `snapshots` are the ones it holds, put in. A snapshot in the file that
/// is in neither — one another device took since this session opened
/// the photograph — is left standing, which is the same rule
/// [`Version::merge`] keeps for a version only one side has: never treat
/// "I did not see it" as "I removed it".
///
/// Each snapshot is re-pointed at `uuid` on the way in, because the
/// default version may have been fused onto a canonical identity since
/// the snapshot was taken, and a snapshot of a uuid nothing carries is
/// a snapshot of nothing.
pub fn replace_snapshots(&mut self, uuid: &str, snapshots: Vec<Version>, removed: &[String]) {
for id in removed {
self.versions.remove(id);
}
for mut snapshot in snapshots {
snapshot.snapshot_of = Some(uuid.to_string());
snapshot.is_default = false;
self.put(snapshot);
}
}
/// TRACES: FR-NC-8 | FR-NC-9
@@ -763,6 +832,10 @@ impl Sidecar {
if v.is_default {
let _ = writeln!(out, "default = 1");
}
// TRACES: FR-DEV-5
if let Some(of) = &v.snapshot_of {
let _ = writeln!(out, "snapshot-of = {of}");
}
let _ = writeln!(out, "revision = {}", v.revision);
if !v.device.is_empty() {
let _ = writeln!(out, "device = {}", v.device);
@@ -942,6 +1015,8 @@ impl Sidecar {
match key {
"name" => version.name = value.to_string(),
"default" => version.is_default = value != "0",
// TRACES: FR-DEV-5
"snapshot-of" => version.snapshot_of = Some(value.to_string()),
"revision" => version.revision = value.parse().unwrap_or(0),
"device" => version.device = value.to_string(),
"modified" => version.modified = value.parse().unwrap_or(0),
@@ -1121,6 +1196,13 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
if !layer.enabled {
let _ = writeln!(out, "enabled = 0");
}
// TRACES: FR-DEV-19a
// The base part's own switch, under a different word from the layer's:
// `enabled` in this block has always meant the layer, and a part that is
// out of the build is `hidden` wherever it is written, base or not.
if layer.base().hidden {
let _ = writeln!(out, "hidden = 1");
}
write_shaping(out, layer.base());
for (op, param, value) in layer.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
@@ -1148,6 +1230,9 @@ fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) {
if part.invert {
let _ = writeln!(out, "invert = 1");
}
if part.hidden {
let _ = writeln!(out, "hidden = 1");
}
write_shaping(out, part);
write_coverage(out, part);
}
@@ -1411,6 +1496,7 @@ struct PartialPart {
/// A colour range's arc: centre and half-width, in turns.
hue: (f32, f32),
invert: bool,
hidden: bool,
/// The *part's* edge transition, distinct from the radial source's own
/// `feather` above — different quantity, different units, different key.
edge_feather: f32,
@@ -1446,6 +1532,7 @@ impl PartialPart {
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
hue: (0.06, 0.05),
invert: false,
hidden: false,
edge_feather: DEFAULT_FEATHER,
falloff: Falloff::default(),
morphology: Morphology::default(),
@@ -1510,6 +1597,9 @@ impl PartialPart {
// and the file's line order is the order they were painted in.
"stroke" => self.strokes.extend(parse_stroke(value)),
"invert" => self.invert = value != "0",
// Absent means shown, so a file from before the switch existed
// reads back with every part in the build, as it was written.
"hidden" => self.hidden = value != "0",
// Clamped, not trusted: a feather wider than the frame is not a
// mask, and a negative one is a distance field read backwards.
"edge-feather" => {
@@ -1611,6 +1701,7 @@ impl PartialPart {
let mut part = MaskPart::new(self.id, self.join, source);
part.invert = self.invert;
part.hidden = self.hidden;
part.feather = self.edge_feather;
part.falloff = self.falloff;
part.morphology = self.morphology;
@@ -2331,6 +2422,89 @@ mod tests {
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
}
/// TRACES: FR-DEV-5
/// A snapshot is a version with a pointer, and the pointer survives the
/// file: it comes back as a snapshot of the edit it was taken from, with
/// the edit it stored, and the photograph still opens at its default.
#[test]
fn a_snapshot_round_trips_as_a_snapshot_of_its_edit() {
let mut sidecar = Sidecar::new();
let mut current = Version::from_graph("u1", "Default", &edited());
current.is_default = true;
sidecar.put(current);
let mut mono = EditGraph::default_chain();
mono.set_param(saturation::ID, saturation::SATURATION, -100.0);
let mut snapshot = Version::from_graph("snap-1", "Black and white", &mono);
snapshot.snapshot_of = Some("u1".into());
snapshot.modified = 7;
sidecar.put(snapshot);
let text = sidecar.to_text();
assert!(text.contains("snapshot-of = u1"), "{text}");
let parsed = Sidecar::parse(&text).expect("valid");
assert_eq!(parsed.default_version().expect("default").uuid, "u1");
let snapshots = parsed.snapshots_of("u1");
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].name, "Black and white");
assert!(snapshots[0].is_snapshot());
let mut g = EditGraph::default_chain();
snapshots[0].apply(&mut g).expect_no_film();
assert_eq!(
g.param(saturation::ID, saturation::SATURATION),
Some(-100.0)
);
}
/// TRACES: FR-DEV-5
/// A file whose only versions are snapshots has no edit to open, and
/// must not answer with one of the snapshots as if it were.
#[test]
fn a_snapshot_is_never_the_default() {
let mut sidecar = Sidecar::new();
let mut snapshot = Version::from_graph("snap-1", "Earlier", &edited());
snapshot.snapshot_of = Some("gone".into());
sidecar.put(snapshot);
assert!(sidecar.default_version().is_none());
}
/// TRACES: FR-DEV-5 | FR-NC-9
/// Writing a session's snapshots back removes what it deleted and keeps
/// what it never saw — another device's snapshot is not this device's to
/// remove by not knowing about it.
#[test]
fn replacing_snapshots_removes_only_what_was_deleted() {
let mut sidecar = Sidecar::new();
let mut current = Version::from_graph("u1", "Default", &edited());
current.is_default = true;
sidecar.put(current);
for id in ["mine-1", "mine-2", "theirs-1"] {
let mut v = Version::from_graph(id, id, &edited());
v.snapshot_of = Some("u1".into());
sidecar.put(v);
}
// This session loaded mine-1 and mine-2, deleted mine-2, took mine-3.
let mut kept = Version::from_graph("mine-1", "mine-1", &edited());
kept.snapshot_of = Some("u1".into());
let taken = Version::from_graph("mine-3", "mine-3", &edited());
sidecar.replace_snapshots("u1", vec![kept, taken], &["mine-2".to_string()]);
let ids: Vec<&str> = sidecar
.snapshots_of("u1")
.iter()
.map(|v| v.uuid.as_str())
.collect();
assert_eq!(ids, ["mine-1", "mine-3", "theirs-1"]);
assert_eq!(
sidecar.versions["mine-3"].snapshot_of.as_deref(),
Some("u1"),
"a snapshot taken without a pointer is pointed at the edit"
);
}
#[test]
fn update_bumps_the_revision() {
// FR-NC-9 resolves by revision; a write that did not bump it would
+42
View File
@@ -1344,6 +1344,48 @@ fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
);
}
/// TRACES: FR-DEV-19a
/// A part left out of the build comes back left out, and the file says so
/// under a word that cannot be confused with the layer's own switch.
#[test]
fn a_hidden_part_stays_hidden_across_a_round_trip() {
let mut graph = EditGraph::default_chain();
let mut layer = corrected("m1", Join::Subtract);
layer.part_mut(1).expect("the correction").hidden = true;
graph.masks_mut().push(layer);
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let text = sidecar.to_text();
assert!(text.contains("hidden = 1"), "{text}");
assert!(
!text.contains("enabled = 0"),
"a hidden part must not read as a disabled layer:\n{text}"
);
let restored = round_trip(&graph);
let layer = &restored.masks().layers()[0];
assert!(layer.parts()[1].hidden, "the correction is still out");
assert!(!layer.parts()[0].hidden, "and the base is still in");
assert!(layer.enabled, "and the layer itself was never switched off");
}
/// TRACES: FR-DEV-19a
/// The base is a part like any other for this: it can be left out, and the
/// key lands in the mask block since that is where the base is written.
#[test]
fn a_hidden_base_is_written_into_the_mask_block() {
let mut graph = EditGraph::default_chain();
let mut layer = corrected("m1", Join::Union);
layer.base_mut().hidden = true;
graph.masks_mut().push(layer);
let restored = round_trip(&graph);
let layer = &restored.masks().layers()[0];
assert!(layer.parts()[0].hidden, "the base came back hidden");
assert!(!layer.parts()[1].hidden, "and only the base");
}
/// A part carries its own edge, which is the whole reason it is a part rather
/// than a second source on the layer: a model's soft coverage and a stroke
/// painted where it stopped short want different boundaries.
@@ -19,11 +19,16 @@ use std::io::{BufRead, BufReader, Write};
#[cfg(unix)]
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::time::Duration;
use dr_sync::RemoteError;
/// How long to wait for the client to acknowledge a command.
///
/// Only the socket path waits; on Windows the client speaks over a named pipe
/// this module does not yet open, so there is nothing to time.
#[cfg(unix)]
const TIMEOUT: Duration = Duration::from_secs(5);
/// A connection to a running desktop client.
+118 -10
View File
@@ -121,16 +121,24 @@ impl NextcloudBackend {
body: Vec<u8>,
) -> Result<Validator, RemoteError> {
let total = body.len() as u64;
// Named from the destination so a resumed or abandoned upload is
// identifiable, and so two uploads cannot collide in one directory.
let token: String = path
.as_str()
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => (b as char).to_string(),
_ => "-".to_string(),
})
.collect();
// TRACES: FR-NC-9
// Named from the destination, so an abandoned upload is identifiable,
// **and from a nonce, so no two uploads ever share a directory.**
//
// The name used to be the destination alone, on the reasoning that two
// *files* could then not collide. Two *devices* uploading the same file
// could, and did: both wrote `00001`…`00009` into one directory, and
// whichever `MOVE`d first assembled a mix of the two — a catalog of
// exactly the right size whose pages came from two different
// databases. SQLite called it malformed, every client then declined to
// overwrite it, and collections stopped syncing on all of them for a
// week. An upload that died on a phone's link left its chunks there
// for the next device to assemble in, by the same mechanism.
let token = format!(
"{}-{}",
sanitise_for_upload_dir(path.as_str()),
upload_nonce()
);
let dir = format!(
"{}/remote.php/dav/uploads/{}/{token}",
self.server, self.login
@@ -138,6 +146,27 @@ impl NextcloudBackend {
self.mkcol_url(&dir).await?;
// Whatever happens below, the directory does not outlive the attempt.
// With a unique name a leftover is only quota rather than corruption,
// but a phone that abandons uploads all day would still leave dozens
// of 5 MB chunks behind, and the server only sweeps them eventually.
let result = self.put_chunks_and_assemble(path, &dir, body, total).await;
if result.is_err() {
self.discard_upload_dir(&dir).await;
}
result
}
/// The transfer half of [`put_chunked`](Self::put_chunked): the chunks,
/// then the `MOVE` that assembles them. Split out so that a failure at any
/// point returns to one place that cleans up.
async fn put_chunks_and_assemble(
&self,
path: &RemotePath,
dir: &str,
body: Vec<u8>,
total: u64,
) -> Result<Validator, RemoteError> {
// Chunks are numbered from 1 and must sort correctly as strings, which
// is why they are zero-padded rather than bare integers.
let chunk_size = CHUNKS.min_chunk as usize;
@@ -191,6 +220,27 @@ impl NextcloudBackend {
self.dir_validator(path).await
}
/// Best-effort `DELETE` of an upload directory whose transfer failed.
///
/// Errors are logged and dropped: this runs on the way out of a failure,
/// and the failure is what the caller needs to hear about. A connection
/// that has just died will refuse this too, and that is fine — the
/// server sweeps abandoned upload directories on its own; this only
/// spares it the wait when the link is still up.
async fn discard_upload_dir(&self, dir: &str) {
let attempt = self
.client
.delete(dir)
.basic_auth(&self.login, Some(&self.password))
.send()
.await;
match attempt {
Ok(resp) if resp.status().is_success() || resp.status() == 404 => {}
Ok(resp) => log::debug!("leaving abandoned upload {dir}: {}", resp.status()),
Err(e) => log::debug!("leaving abandoned upload {dir}: {e}"),
}
}
/// `MKCOL` at an absolute URL, treating "already there" as success.
async fn mkcol_url(&self, url: &str) -> Result<(), RemoteError> {
let resp = self
@@ -725,6 +775,37 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
}
}
/// The destination path reduced to characters every WebDAV server accepts in
/// an upload directory name.
fn sanitise_for_upload_dir(path: &str) -> String {
path.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => (b as char).to_string(),
_ => "-".to_string(),
})
.collect()
}
/// A token no other upload — on this device or any other — will produce.
///
/// Nanoseconds since the epoch, the process id, and a counter, mixed rather
/// than concatenated so the name stays short. Two devices would have to start
/// an upload in the same nanosecond from the same pid to collide, and the
/// counter separates two uploads this process starts in one tick. No
/// randomness crate is pulled in for this: unique is the requirement, not
/// unguessable.
fn upload_nonce() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let pid = u64::from(std::process::id());
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{:016x}", nanos ^ (pid << 40) ^ n.rotate_left(20))
}
/// Translate an HTTP status into a typed error.
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
match status.as_u16() {
@@ -765,6 +846,33 @@ fn encode_path(path: &str) -> String {
#[cfg(test)]
mod tests {
#[test]
fn two_uploads_of_one_destination_never_share_a_directory() {
// The collision that assembled two devices' chunks into one file.
// Same path, back to back, same process: still two names.
let a = format!(
"{}-{}",
super::sanitise_for_upload_dir("PhotosRaw/.darkroom-derived/catalog.sqlite"),
super::upload_nonce()
);
let b = format!(
"{}-{}",
super::sanitise_for_upload_dir("PhotosRaw/.darkroom-derived/catalog.sqlite"),
super::upload_nonce()
);
assert_ne!(a, b);
assert!(a.starts_with("PhotosRaw--darkroom-derived-catalog-sqlite-"));
}
#[test]
fn upload_directory_names_are_plain() {
assert_eq!(
super::sanitise_for_upload_dir("Photos Raw/été/x.sqlite"),
"Photos-Raw---t---x-sqlite"
);
assert!(super::upload_nonce().bytes().all(|b| b.is_ascii_hexdigit()));
}
use super::*;
fn backend() -> NextcloudBackend {
+10 -4
View File
@@ -67,15 +67,21 @@ pub fn set_data_dir(dir: PathBuf) {
let _ = DATA_DIR.set(dir);
}
/// The directory the platform entry point declared, if it declared one.
///
/// Android does; a desktop does not, and resolves through `dr_plat::dirs`
/// instead. Exposed so a caller wanting the *data* directory can honour the
/// same declaration without inheriting the config rule as its fallback.
pub fn declared_data_dir() -> Option<PathBuf> {
DATA_DIR.get().cloned()
}
/// The directory configuration lives in.
pub fn config_dir() -> PathBuf {
if let Some(d) = DATA_DIR.get() {
return d.clone();
}
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"))
.join("darkroom")
dr_plat::base_dir(dr_plat::Base::Config)
}
/// TRACES: FR-NC-12
+31 -5
View File
@@ -37,6 +37,17 @@ pub struct ScanProgress {
/// on the whole edit format to recognise four characters in a filename.
pub const SIDECAR_EXTENSION: &str = "drsc";
/// TRACES: FR-CAT-13
/// Extension of a standard XMP sidecar — Lightroom's `IMG_0001.xmp`,
/// darktable's `IMG_0001.CR3.xmp`, and every other editor's.
///
/// Collected alongside DarkRoom's own for the same reason and at the same
/// cost: it is in the listing already, and it is the file the ratings and
/// keywords of a library edited elsewhere are in. Which images a given
/// `.xmp` describes is the catalog's question, since the two naming
/// conventions resolve differently and only the catalog knows the images.
pub const XMP_EXTENSION: &str = "xmp";
/// The result of a scan.
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
@@ -275,15 +286,18 @@ where
Ok(result)
}
/// Whether a filename is a DarkRoom sidecar.
/// Whether a filename is a sidecar — DarkRoom's own, or a standard XMP one.
///
/// Case-insensitive on the extension alone. A server that upper-cased the
/// suffix — or a file copied through a filesystem that did — still describes a
/// photograph, and failing to recognise it would silently lose the edit rather
/// than fail visibly.
fn is_sidecar(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(stem, ext)| !stem.is_empty() && ext.eq_ignore_ascii_case(SIDECAR_EXTENSION))
name.rsplit_once('.').is_some_and(|(stem, ext)| {
!stem.is_empty()
&& (ext.eq_ignore_ascii_case(SIDECAR_EXTENSION)
|| ext.eq_ignore_ascii_case(XMP_EXTENSION))
})
}
/// Whether pruning is worth attempting against this backend.
@@ -927,6 +941,10 @@ mod tests {
// Upper-cased by a filesystem somewhere along the way; still a
// sidecar, and losing it would lose the edit silently.
file("Photos/2025/b.DRSC"),
// TRACES: FR-CAT-13
// And the standard kind, in both of its spellings.
file("Photos/2025/a.xmp"),
file("Photos/2025/b.jpg.xmp"),
],
);
let before = *b.lists.borrow();
@@ -946,14 +964,22 @@ mod tests {
.iter()
.map(|e| e.path.as_str().to_string())
.collect::<Vec<_>>(),
vec!["Photos/2025/a.drsc", "Photos/2025/b.DRSC"]
vec![
"Photos/2025/a.drsc",
"Photos/2025/a.xmp",
"Photos/2025/b.DRSC",
"Photos/2025/b.jpg.xmp",
]
);
assert_eq!(*b.lists.borrow() - before, 3, "no extra requests");
// And they are not photographs: the count the user is shown must not
// double because a library has been edited.
assert_eq!(r.progress.images_found, 3);
assert!(r.images.iter().all(|e| !e.path.name().contains("drsc")));
assert!(r
.images
.iter()
.all(|e| !e.path.name().contains("drsc") && !e.path.name().contains("xmp")));
}
/// A file whose *name* is only an extension is not a sidecar for anything.
+13 -1
View File
@@ -89,6 +89,15 @@ pub struct LibrarySettings {
/// 64 bars on a phone held in the hand is finer than a finger can aim at;
/// 32 on a desktop monitor wastes most of a tall sidebar.
pub timeline_bars: u32,
/// TRACES: FR-CAT-13 | NFR-R4
/// Whether a judgement is also written to the standard XMP sidecar beside
/// the original — `IMG_0001.xmp`, or `IMG_0001.CR3.xmp` where one exists.
///
/// Off by default, because NFR-R4 says writes beside somebody's originals
/// are theirs to switch on. Reading is not gated: a sidecar another
/// editor wrote is taken in regardless, since reading changes nothing in
/// the folder.
pub write_xmp_sidecars: bool,
}
impl LibrarySettings {
@@ -106,7 +115,10 @@ impl Default for LibrarySettings {
// The coarser of the two. A bar has to be wide enough to hit with a
// finger before it has to be narrow enough to be precise, and the
// smallest screen is the one where getting this wrong hurts most.
Self { timeline_bars: 32 }
Self {
timeline_bars: 32,
write_xmp_sidecars: false,
}
}
}
+107
View File
@@ -0,0 +1,107 @@
# DarkRoom — reproducible Windows cross-build environment
#
# Everything docs/windows.md §2 names: Rust with the GNU Windows target, the
# MinGW-w64 cross compiler it links with, NSIS to build the installer, and Wine
# to smoke-test the result. Both CI and local builds use this image, so "works
# on my machine" and "works in CI" are the same machine — the same argument
# docker/android makes, and the same shape.
#
# Build: docker build -t darkroom-windows:latest docker/windows
# Use: ./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
# trixie rather than the Android image's bookworm, for Wine: rustc's std
# imports bcryptprimitives.dll for its random source, and bookworm's Wine 8.0
# does not have it, so the smoke test dies at load with c0000135 before a
# single instruction of the application runs. Wine 10 does. trixie also ships
# Node 20 itself, so the NodeSource step the Android image needs is not here.
FROM docker.io/library/debian:trixie-slim
# ---------------------------------------------------------------------------
# Versions — pinned deliberately, like the Android image.
# ---------------------------------------------------------------------------
ARG RUST_VERSION=1.92.0
ENV DEBIAN_FRONTEND=noninteractive \
CARGO_HOME=/opt/cargo \
RUSTUP_HOME=/opt/rustup \
PATH=/opt/cargo/bin:$PATH
# ---------------------------------------------------------------------------
# System packages
# ---------------------------------------------------------------------------
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git git-lfs \
# A *host* C compiler as well as the cross one: build scripts and
# proc-macros are compiled for Linux and linked with `cc`, whatever
# the target. Without it the very first build script fails with
# "linker `cc` not found" before any Windows code is reached.
gcc libc6-dev \
# The cross compiler, binutils and the MinGW runtime headers/libs. This
# is the one C toolchain the target needs: bundled SQLite, ring's asm
# and anything else the cc crate builds for the target go through it.
gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 \
# The installer compiler. A native Linux binary; NSIS has always built
# its installers on POSIX hosts.
nsis \
# Runs the .exe and the installer for the smoke tests (windows.md §6).
# Not needed to build anything. Both packages: `wine64` is the
# loader under /usr/lib/wine, `wine` is the wrapper on PATH.
wine wine64 \
# `file` reports PE32+; `xz-utils` because the mingw packages are
# compressed with it.
file xz-utils \
# Gitea runs JavaScript actions (checkout, cache) from inside the job
# container, and current actions want Node 20 or newer.
nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version
# ---------------------------------------------------------------------------
# Rust + the Windows target
#
# The component list must be a superset of rust-toolchain.toml's, for the
# reason the Android Dockerfile gives: rustup reconciles that file on the
# first cargo invocation and downloads anything missing inside the job.
# ---------------------------------------------------------------------------
RUN curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain ${RUST_VERSION} \
&& rustup target add x86_64-pc-windows-gnu \
&& rustup component add rustfmt clippy rust-analyzer \
&& chmod -R a+rwX ${CARGO_HOME} ${RUSTUP_HOME}
# ---------------------------------------------------------------------------
# Linker configuration
#
# Debian ships the cross compiler in two thread models and the bare name is an
# alternatives symlink. `-posix` is stated: it is the one whose libstdc++ and
# libwinpthread the Rust target's own MinGW pieces were built against, and
# picking the other produces link errors that read as if std were missing.
#
# The runtime is linked statically (docs/windows.md §2) so the installer
# carries one file. `-static-libgcc` is all it takes: rustc's windows-gnu
# target links its own copy of winpthread in self-contained mode, so nothing
# imports libwinpthread-1.dll — the smoke test's objdump step is what checks
# that. The `--whole-archive -lwinpthread` incantation the spec first named is
# wrong here: it forces in unused winpthread objects whose kernel32 and
# msvcrt references come after those libraries on the link line, and the
# link fails on a hundred undefined `__imp_` symbols.
# ---------------------------------------------------------------------------
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc-posix \
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-args=-static-libgcc -C link-args=-static-libstdc++" \
CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc-posix \
CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++-posix \
AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc-ar-posix \
WINDRES=x86_64-w64-mingw32-windres
# Wine writes its prefix under $HOME and refuses a directory it does not own.
# The caller passes --user, so nothing baked into the image can be owned by
# that user; build.sh bind-mounts a host directory here instead, which also
# keeps the prefix (and its slow first `wineboot`) across runs.
ENV HOME=/tmp/home \
WINEDEBUG=-all
VOLUME ["/opt/cargo/registry"]
WORKDIR /work
CMD ["/bin/bash"]
+61
View File
@@ -0,0 +1,61 @@
# Windows cross-build environment
Reproducible container for building the Windows executable and its installer from Linux. The
specification is [docs/windows.md](../../docs/windows.md); this directory is what it turned into,
and every departure from the spec's first draft is recorded in the Dockerfile's comments.
## Use
```bash
# Cross-compile the desktop application
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
# Lint the cfg(windows) branches, which the Linux job never sees
./docker/windows/build.sh cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop -- -D warnings
# Build the installer from that binary
./docker/windows/build.sh docker/windows/package.sh
# Smoke-test under Wine (docs/windows.md §6)
./docker/windows/build.sh wine target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe --version
./docker/windows/build.sh wine target-windows/installer/DarkRoom-0.12.0-x86_64-setup.exe /S
# Interactive shell
./docker/windows/build.sh
# After editing the Dockerfile
./docker/windows/build.sh --rebuild
```
Prefers `podman`, falls back to `docker`. The cargo registry, the target directory and the Wine
prefix persist under `~/.cache/darkroom-windows/`, so a warm rebuild is minutes and the first
`wineboot` happens once.
## What is verified here, and what is not
Measured on the first build, 2026-09-12:
| Check | Result |
|---|---|
| `cargo build --target x86_64-pc-windows-gnu -p darkroom-desktop` | Links. 115 MB, `PE32+ … (GUI)` |
| DLL imports | 26 Windows system DLLs; **no** `libwinpthread-1.dll`, `libgcc_s`, `libstdc++` |
| `wine darkroom-desktop.exe --version` | `darkroom-desktop 0.12.0`, exit 0, 0.1 s |
| `makensis` | 105 MB `DarkRoom-<version>-x86_64-setup.exe`, 64-bit stub |
| `wine setup.exe /S` | Installs exe + 7 models to `AppData\Local\Programs\DarkRoom`, writes the `HKCU` uninstall key; the installed exe runs |
| `wine uninstall.exe /S` | Removes the directory and the key |
| Start Menu shortcut | **Not verifiable here.** `CreateShortcut` is `IShellLink` and does nothing under a headless Wine; the directory beside it is created. Check on Windows. |
Also checked by running the application itself under Wine: its log lands in
`AppData\Local\darkroom\state`, and nothing is written outside `AppData` (FR-PLAT-WIN-1).
None of this proves a Vulkan device opens, a render completes, fonts are found, the secret store
round-trips or the browser opens for sign-in. Those are a Windows machine, once per release.
## In CI
`.gitea/workflows/windows-image.yml` builds this image and pushes it to
`gitea.tourolle.paris/dtourolle/darkroom-windows`, exactly as the Android image is handled — tagged
by the tree hash of `docker/windows/`, rebuilt when and only when a file here changes. The
`windows` job in `build-and-test.yml` then runs, inside it, the same commands listed above plus
`cargo clippy --target x86_64-pc-windows-gnu -- -D warnings`, and uploads the installer as an
artefact.
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Run a command inside the DarkRoom Windows cross-build container.
#
# ./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
# ./docker/windows/build.sh cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop
# ./docker/windows/build.sh # interactive shell
#
# Builds the image on first use. Rebuild after editing the Dockerfile with:
# ./docker/windows/build.sh --rebuild
set -euo pipefail
IMAGE="darkroom-windows:latest"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${HERE}/../.." && pwd)"
# Prefer podman (rootless by default); fall back to docker.
if command -v podman >/dev/null 2>&1; then
ENGINE=podman
elif command -v docker >/dev/null 2>&1; then
ENGINE=docker
else
echo "error: neither podman nor docker found" >&2
exit 1
fi
if [[ "${1:-}" == "--rebuild" ]]; then
shift
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
elif ! "${ENGINE}" image exists "${IMAGE}" 2>/dev/null && \
! "${ENGINE}" image inspect "${IMAGE}" >/dev/null 2>&1; then
echo "==> building ${IMAGE} (first run; several minutes)"
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
fi
# Persist the cargo registry and target dir across runs, or every build
# re-downloads the crate index.
CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-windows"
# `home` is the container's $HOME: Wine keeps its prefix there for the smoke
# tests, and refuses one it does not own — which rules out anything the image
# could have created, since the container runs as the host user.
mkdir -p "${CACHE}/registry" "${CACHE}/target" "${CACHE}/home"
ARGS=(
--rm
-v "${REPO}:/work:z"
-v "${CACHE}/registry:/opt/cargo/registry:z"
-v "${CACHE}/target:/work/target-windows:z"
-v "${CACHE}/home:/tmp/home:z"
-e CARGO_TARGET_DIR=/work/target-windows
-w /work
)
# Build parallelism. A full cross-compile will otherwise take every thread on
# the host and make the machine unusable for the length of the build, which is
# a poor trade when it is running in the background.
#
# Both halves are needed: CARGO_BUILD_JOBS caps how many rustc processes cargo
# starts, while --cpus caps what the container gets no matter what any nested
# build script decides to spawn (cc, cmake, and ring's asm build all parallelise
# on their own account and do not consult cargo).
JOBS="${DARKROOM_BUILD_JOBS:-8}"
if [[ "${JOBS}" != "0" ]]; then
ARGS+=(--cpus "${JOBS}" -e "CARGO_BUILD_JOBS=${JOBS}")
fi
# Rootless podman already maps the host user; docker needs it stated.
if [[ "${ENGINE}" == "docker" ]]; then
ARGS+=(--user "$(id -u):$(id -g)")
fi
if [[ $# -eq 0 ]]; then
ARGS+=(-it)
set -- /bin/bash
fi
exec "${ENGINE}" run "${ARGS[@]}" "${IMAGE}" "$@"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Assemble the Windows installer from a finished cross-build.
#
# ./docker/windows/build.sh docker/windows/package.sh
#
# Expects `cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop`
# to have run in the same target directory. Produces
# DarkRoom-<version>-x86_64-setup.exe in $OUT (default: target-windows/installer).
#
# Runs inside the container, where makensis is; docs/windows.md §5 is the
# specification this implements.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${HERE}/../.." && pwd)"
# Absolute, whatever CARGO_TARGET_DIR was. NSIS on a POSIX host translates
# the backslashes in `${STAGE}\LICENSE` only when it can tell the path is a
# POSIX one, which it decides from a leading `/`; a relative `target-windows`
# — which is exactly what CI sets — reaches it as a name with a literal
# backslash in it and "LicenseData: open failed". The first CI run died there,
# after every step before it had passed.
TARGET_DIR="$(realpath -m "${CARGO_TARGET_DIR:-${REPO}/target}")"
TARGET="${TARGET_DIR}/x86_64-pc-windows-gnu/release"
OUT="$(realpath -m "${OUT:-${TARGET_DIR}/installer}")"
EXE="${TARGET}/darkroom-desktop.exe"
[[ -f "${EXE}" ]] || { echo "error: ${EXE} not built" >&2; exit 1; }
# The version, from the workspace rather than restated here — the same single
# source tools/set-version.sh writes and the APK packager reads.
VERSION="$(sed -n 's/^version = "\(.*\)"$/\1/p' "${REPO}/Cargo.toml" | head -1)"
[[ -n "${VERSION}" ]] || { echo "error: no version in Cargo.toml" >&2; exit 1; }
echo "==> version ${VERSION}"
# Staging: exactly what the installer carries (windows.md §5.2), and nothing
# from a previous run — a model dropped from the tree must not linger here
# and go on shipping.
STAGE="${OUT}/stage"
rm -rf "${STAGE}"
mkdir -p "${STAGE}/models"
cp "${EXE}" "${STAGE}/darkroom.exe"
# CRLF for the licence page: it is shown in a Windows edit control, which
# draws a bare LF as nothing and runs the whole GPL together.
sed 's/$/\r/' "${REPO}/LICENSE" > "${STAGE}/LICENSE"
# The models, with the guard every other packager carries: an LFS pointer is
# ~130 bytes and looks exactly like a model to `cp`. Shipped, it fails inside
# tract on the user's machine with a message about a broken graph rather than
# a checkout that needed `git lfs pull`. Only the weights are checked; the
# scene model's vocabulary and category descriptor are legitimately small.
for dir in face scene; do
for f in "${REPO}/models/${dir}"/*; do
case "$(basename "${f}")" in
README.md) continue ;;
esac
if [[ "${f}" == *.onnx && "$(stat -c%s "${f}")" -lt 100000 ]]; then
echo "error: $(basename "${f}") is $(stat -c%s "${f}") bytes — an LFS pointer, not a model." >&2
echo " run: git lfs pull" >&2
exit 1
fi
cp "${f}" "${STAGE}/models/"
done
done
echo "==> staged $(ls "${STAGE}/models" | wc -l) model file(s)"
# One installer in the output directory, the one just built. The directory
# is cached between CI runs, so after a version bump a glob over it would find
# two and the smoke test would hand Wine both names as one path.
rm -f "${OUT}"/DarkRoom-*-x86_64-setup.exe
INSTALLER="${OUT}/DarkRoom-${VERSION}-x86_64-setup.exe"
# NSIS wants Windows-style paths in File directives even on a POSIX host, and
# the script takes its inputs by define so nothing about the layout here is
# written into it.
makensis -V2 \
-DVERSION="${VERSION}" \
-DSTAGE="${STAGE}" \
-DOUT="${INSTALLER}" \
"${REPO}/packaging/windows/darkroom.nsi"
ls -la "${INSTALLER}"
echo "==> ${INSTALLER}"
+4 -1
View File
@@ -22,8 +22,9 @@ permission to a package rather than after.
| Linux | AppImage | v1 channel, **recipe not yet written** (§5) | Oldest supported glibc, and no sandbox at all |
| Android | F-Droid | v1 channel, not yet submitted | GPLv3-clean build, reproducible, no proprietary blobs |
| Android | Play Store | **Not v1** (§6) | Would make ARCH §6.9 binding as policy rather than as engineering |
| Windows | NSIS per-user installer, cross-built — [windows.md](windows.md) | Built by CI, **untested on Windows**. Not v1 | Known folders in place of XDG; no sandbox; unsigned until there is a certificate |
Three of these five exist as recipes and two do not. That is stated rather than
Four of these six exist as recipes and two do not. That is stated rather than
smoothed over, because the value of writing the channels down is knowing which
constraints are already being met and which are promises.
@@ -245,6 +246,8 @@ packaging/
paris.tourolle.darkroom.metainfo.xml AppStream, installed by every channel
flatpak/
paris.tourolle.darkroom.yml the manifest, and where the permissions are argued
windows/
darkroom.nsi the installer; docker/windows/package.sh drives it
```
`packaging/` also accumulates built `.pkg.tar.zst` artefacts from local
+44 -15
View File
@@ -5,7 +5,7 @@
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
37 gestures, in 4 places.
40 gestures, in 4 places.
## Develop
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
<sub>`ui/dr-ui/ui/app.slint:1950`</sub>
<sub>`ui/dr-ui/ui/app.slint:1974`</sub>
### Move a magnified photograph about
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2041`</sub>
<sub>`ui/dr-ui/ui/app.slint:2065`</sub>
### Paint a mask by hand
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2128`</sub>
<sub>`ui/dr-ui/ui/app.slint:2152`</sub>
### Take back the last change
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2349`</sub>
<sub>`ui/dr-ui/ui/app.slint:2373`</sub>
### Do it again after taking it back
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z
<sub>`ui/dr-ui/ui/app.slint:2362`</sub>
<sub>`ui/dr-ui/ui/app.slint:2386`</sub>
### Copy the settings from this photograph
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2395`</sub>
<sub>`ui/dr-ui/ui/app.slint:2419`</sub>
### Paste the settings onto this photograph
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2407`</sub>
<sub>`ui/dr-ui/ui/app.slint:2431`</sub>
### Change which group of adjustments is on screen
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2435`</sub>
<sub>`ui/dr-ui/ui/app.slint:2459`</sub>
### Look at the photograph at 1:1
@@ -101,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
<sub>`ui/dr-ui/ui/app.slint:2470`</sub>
<sub>`ui/dr-ui/ui/app.slint:2494`</sub>
### Move to the next or previous photograph
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
<sub>`ui/dr-ui/ui/app.slint:2500`</sub>
<sub>`ui/dr-ui/ui/app.slint:2546`</sub>
### See the photograph before you edited it
@@ -121,25 +121,45 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2624`</sub>
<sub>`ui/dr-ui/ui/app.slint:2670`</sub>
### Put one control back to its default
- **Touch** — Double-tap its track
- **Pointer** — Double-click its track, or right-click it
- **Keyboard** — R, for the control last moved
The column is 280px wide and the colour mixer alone puts thirty-six of these in it, so a reset button per row would be most of the width. Two ways in with a pointer because right-click is the one a hand already reaches for and double-click is the one that needs no second button. A group's own reset is in its heading; this is the single control.
<sub>`ui/dr-ui/ui/controls.slint:294`</sub>
### See the photograph as a snapshot had it
- **Touch** — Press and hold the eye beside the snapshot
- **Pointer** — Press and hold the eye beside the snapshot
The same hold as "Before", against a point the photographer chose rather than the file: "the version I liked twenty minutes ago" is how a choice between two treatments is actually made. It takes no history step and changes nothing; letting go puts the edit back.
<sub>`ui/dr-ui/ui/history.slint:92`</sub>
### Keep the photograph as it is now, under a name
- **Touch** — Type a name in the History panel and press Snapshot
- **Pointer** — Type a name in the History panel and press Snapshot
The history is forgotten with the sitting, on purpose; a snapshot is the photographer saying this one should not be. It is written into the sidecar as a version of the edit, so it survives a restart and reaches the other device. Pressing a snapshot puts the photograph back to it, as one step that undo takes back whole.
<sub>`ui/dr-ui/ui/history.slint:307`</sub>
### Show or hide one mask layer
- **Touch** — Tap the ring at the head of its row
- **Pointer** — Click the ring at the head of its row
- **Keyboard** — H, for the selected layer history step, unlike holding "Before" — the layer really is off until it is switched back on.
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a history step, unlike holding "Before" — the layer really is off until it is switched back on.
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a
<sub>`ui/dr-ui/ui/masks.slint:209`</sub>
<sub>`ui/dr-ui/ui/masks.slint:212`</sub>
### Show or hide one mask on the photograph
@@ -148,7 +168,16 @@ Disabling a layer is the before-and-after a local edit constantly wants, so it i
A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step.
<sub>`ui/dr-ui/ui/masks.slint:274`</sub>
<sub>`ui/dr-ui/ui/masks.slint:279`</sub>
### Leave one part out of a mask, and put it back
- **Touch** — Tap the ring on the part's row
- **Pointer** — Click the ring on the part's row
The question a correction raises is whether it did what it was for — whether the stroke filled the shoulder, whether the subtracted gradient took only the sky. Removing it answers that and loses it. The same ring the layer wears, one row down, because it is the same question about a smaller thing.
<sub>`ui/dr-ui/ui/masks.slint:394`</sub>
## Collections sidebar
+31 -24
View File
@@ -244,11 +244,14 @@ requirement singles out are missing: whether `shaderFloat16` and 16-bit storage
one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device
from a second GPU vendor.
**NFR-OPS-2 and NFR-OPS-4.** Crash reporting is a `log::error!` panic hook on Android and nothing at
all on desktop: no local crash record, no backtrace capture, no upload path and therefore no opt-in
gate to guard it. Update and first run are undefined; the concrete reason NFR-OPS-4 gives — that D2
pins rawler at a non-SemVer alpha whose camera-support fixes users will need — is unaddressed, and
there is no update mechanism of any kind.
**NFR-OPS-2 is met, and NFR-OPS-4 is not.** Crash reporting is `platform/dr-plat/src/crash.rs`: a
panic on either platform writes a local record with a redacted message and backtrace, ten are kept,
and there is deliberately no upload path — the requirement's "upload only on explicit opt-in" is
satisfied by there being nothing to opt into, and the module says why a transport built ahead of the
consent is the wrong order. (This paragraph said the opposite until 2026-09-12; the record had landed
on 2026-08-30 and the paragraph had not been read against it.) Update and first run are undefined;
the concrete reason NFR-OPS-4 gives — that D2 pins rawler at a non-SemVer alpha whose camera-support
fixes users will need — is unaddressed, and there is no update mechanism of any kind.
---
@@ -305,7 +308,7 @@ well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library int
gap is narrower than it reads. It is the *first* build against a large remote library that pays, and
that is the moment a new user meets.
**FR-CAT-13 — XMP interoperability, built but not yet wired.** `core/dr-xmp` now reads and writes
**FR-CAT-13 — XMP interoperability, wired on 2026-09-12.** `core/dr-xmp` reads and writes
standard XMP sidecars: `dc:subject` and `lr:hierarchicalSubject`, `xmp:Rating` and `xmp:Label`, and
the IPTC core fields, in both the attribute and the element form and whatever RDF container a file
happened to use. It states the ownership rule in one place — DarkRoom owns the properties in
@@ -313,20 +316,22 @@ happened to use. It states the ownership rule in one place — DarkRoom owns the
and enforces it by rewriting a packet event by event rather than serialising over it, so another
application's `crs:` settings, comments and processing instructions survive a write byte for byte.
**What remains is the wiring, and it is the larger half.** Nothing above the crate calls it: no scan
finds a `.xmp` beside a raw, no catalog row is populated from one, no edit writes one back, and the
external-modification detection the requirement also asks for does not exist. Two smaller gaps go
with it — GPS is not carried (`exif:GPSLatitude` is a format of its own, and `dr-decode` produces no
location for it to carry yet, which `dr-export`'s metadata module says about its own half), and the
filename convention is left to the caller, because Lightroom writes `IMG_0001.xmp` and darktable
writes `IMG_0001.CR3.xmp` and finding a file is not this crate's business.
**The wiring is `ui/dr-ui/src/xmp_sync.rs`.** The scan collects `.xmp` beside `.drsc` from the
listings it was already making; the pull reads each one whose ETag has moved and reconciles it
against the catalog with the catalog winning — keywords union, and a rating, label or caption taken
only where the catalog holds none. Both naming conventions resolve: `IMG_0001.CR3.xmp` names its
file, `IMG_0001.xmp` the stem, and the JPEG beside a RAW is the same photograph. A genuine
disagreement is written to `xmp_conflicts` and the settings page offers "Take the sidecars' values",
which is the reload the requirement asks for; the detection it asks for is the ETag that moved. The
write in the other direction is behind a setting that starts off (NFR-R4): a judgement or a keyword
then also rewrites the sidecar beside the original, keeping the file's own caption, copyright and
hierarchy, which the catalog has no columns for and would otherwise have deleted.
The precedence question is settled conservatively rather than fully: keywords union, following
`dr_catalog::merge`, and every other field is taken only where DarkRoom holds none, following
`Version::merge`'s judgement rule — because a standard XMP carries no revision and no device, so
FR-NC-9's ordering cannot be performed against it. What is *not* settled, and is written down in the
crate rather than guessed at, is when a reload may happen without asking; see the module
documentation's "The open question".
**What remains.** GPS is not carried — `exif:GPSLatitude` is a format of its own and `dr-decode`
produces no location for it to carry yet. Title, description and copyright are read and reconciled
but the catalog has nowhere to put them, so they pass through a rewrite rather than being editable.
An XMP write made offline is not queued: the catalog and the `.drsc` are authoritative, and the next
judgement online writes the file whole again.
`dr-preset-xmp` remains what it always was and is still not the counter-example it looks like: a
reader of Lightroom *presets* under FR-DEV-6, a different file for a different purpose.
@@ -388,11 +393,13 @@ line — and R1 is untagged again, which is the honest reading while it has no a
tag anything against.
NFR-OPS-1 was covered by tags that were real rather than fixtures, which is the worse case of the
two: one on `compute_coverage` and one on the gesture extractor, both on the traceability tool. A
coverage calculation and a documentation generator are not diagnostics under any reading, and the
requirement asks for a rotating, size-capped on-disk log in the XDG state directory, credential
redaction, and a consented diagnostics bundle. None of that exists — logging goes to stderr and
logcat — so both tags have been removed and NFR-OPS-1 is untagged. It is the case
[CONTRIBUTING.md](../CONTRIBUTING.md) warns about in its own words: a tag proves a tag exists.
coverage calculation and a documentation generator are not diagnostics under any reading, so both
tags were removed. It is the case [CONTRIBUTING.md](../CONTRIBUTING.md) warns about in its own words:
a tag proves a tag exists. The requirement has since been built where it says: the rotating,
size-capped log and its redaction in `platform/dr-plat/src/diagnostics.rs` (2026-08-30), and the
bundle in `diagnostics/bundle.rs` (2026-09-12) — the log, the crash records, the version, the schema
and the GPU as one text file, shown in full in Settings before a second press writes it, and sent
nowhere by either press.
**R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure
TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated
+29
View File
@@ -533,6 +533,17 @@ which knows nothing of a layer's shaping and nothing at all about a gradient, a
so choosing a subject or a category produced a layer whose extent was invisible, and every control
in FR-DEV-19a and FR-DEV-19b acted on something the photographer could not see.
**FR-DEV-16 — A keyboard vocabulary for develop.** Every develop gesture that can be reached from
the keyboard is bound, tagged beside its implementation, and generated into the gesture book
(FR-UI-4): stepping through the folder, fit and 1:1, holding the original, undo and redo, copying
and pasting settings, cycling the adjustment groups, resetting the control last moved, and showing
or hiding the selected mask layer. None is keyboard-only — each has a pointer and a touch route
(FR-DEV-3b) — and the book is regenerated from the tags, so it cannot describe a binding the
application does not have.
Editing rhythm depends on the hands staying put: reaching for a menu breaks the concentration of an
edit the same way it breaks the pace of a cull. A binding nobody can discover is the same as no
binding, which is why the generated book is part of the requirement rather than documentation of it.
### 3.4 Display and interaction
**FR-DSP-1 — Proxy-resolution rendering.** The develop view renders at the resolution actually
@@ -1009,6 +1020,24 @@ protocol is unavailable, FR-DSP-8's stated fallback applies.
portals and credential storage uses the Secret Service portal, both verified to satisfy FR-NC-2 and
FR-CAT-1 within the sandbox.
#### Windows
Specified in [windows.md](windows.md); a stated channel under NFR-COMPAT-2, not a v1 one.
**FR-PLAT-WIN-1 — Known folders.** Configuration under `%APPDATA%\darkroom`; data, cache and
state under `%LOCALAPPDATA%\darkroom`. Nothing under the profile root and nothing relative to the
working directory. The layout beneath those roots is the same as under XDG, so a library directory
moves between platforms unchanged.
**FR-PLAT-WIN-2 — Installer.** A per-user installer that needs no elevation, registers an
uninstaller, and whose uninstaller removes what the installer wrote and nothing the application
wrote. Models are installed beside the executable and found there last, after the user's own
directories.
**FR-PLAT-WIN-3 — Built from Linux.** The Windows binary and its installer are produced by the
Linux CI from the same commit as every other channel, with no Windows machine in the build.
Verification on Windows is a release step, recorded per release, not a build step.
### 3.9 Culling
Per D11 this is the product's primary differentiator, not an incidental capability.
+100 -96
View File
File diff suppressed because one or more lines are too long
+486
View File
@@ -0,0 +1,486 @@
# DarkRoom — A Windows installer from the Linux CI
**Satisfies:** FR-PLAT-WIN-1 · FR-PLAT-WIN-2 · FR-PLAT-WIN-3 · NFR-COMPAT-2 (a stated channel)
**Companion to:** [distribution.md](distribution.md) · [requirements.md](requirements.md) §3.8, §4.4 ·
[android-signing.md](android-signing.md)
Spec for producing `DarkRoom-<version>-x86_64-setup.exe` from the same Gitea runner that builds the
Arch package and the APK, with no Windows machine in the loop. It names the toolchain, what the
tree has to change to compile for the target, what the installer does, how the CI job is shaped,
and — because there is no Windows hardware on the runner — exactly how much of the result can be
verified before a person double-clicks it.
**Written as a spec; §10 is the report.** Every step of §9 has since been run —
[`docker/windows/`](../docker/windows/) is the container, [`packaging/windows/darkroom.nsi`](../packaging/windows/darkroom.nsi)
the installer, [`.gitea/workflows/windows-image.yml`](../.gitea/workflows/windows-image.yml) and
the `windows` job in `build-and-test.yml` the CI leg, and §6's gate passes through row 4 under
Wine. Four claims in the first draft were wrong and are corrected in place with a note; §10 lists
them. Where a claim still rests on reading rather than running, it says so.
---
## 1. Why this is nearly free, and where it is not
The reason to write this at all is that the tree is closer to Windows than a Linux-only project
usually is. The three things that ordinarily make a cross-build to Windows a week of work are all
absent:
| Usual obstacle | Here |
|---|---|
| A C image library (libraw, libjpeg-turbo, lcms) | `rawler`, `zune-jpeg`, `jpeg-encoder`, all pure Rust |
| OpenSSL, or a TLS stack with a system dependency | `reqwest` on `rustls` + `webpki-roots`; `ring` cross-compiles to the GNU target |
| A GUI toolkit with a platform-specific build | Slint on `winit` + wgpu, which already runs the same code on Linux and Android |
`rusqlite` is `bundled`, so SQLite compiles with whatever C compiler the target has; that is the one
place a cross C compiler is required, and it is a package install rather than a port. The inference
engine is tract, in Rust, which is what [faces.md §3](faces.md) chose it for — and this is the
second time that choice pays: the C++ ONNX Runtime would have needed a prebuilt Windows binary
fetched at build time.
**Where it is not free** is `dr-plat` and the handful of paths above it, which is exactly where
NFR-PORT-1 says platform code should be and where §3 finds it. That the list in §3 is short and
every item on it is already behind a `cfg` is the measure of whether NFR-PORT-3 ("a third platform
requires implementing the platform interfaces only") was met. It was, nearly: the gaps are in
things that grew *above* `dr-plat` — a settings file path, an `xdg-open` — rather than in the
interfaces themselves.
---
## 2. Toolchain: the GNU target, from a container
Two Rust targets can produce a Windows binary from Linux.
| Target | Linker | What it needs on the runner | What it costs |
|---|---|---|---|
| **`x86_64-pc-windows-gnu`** | MinGW-w64 `gcc` | `gcc-mingw-w64-x86-64` (Debian/Ubuntu), `mingw-w64-gcc` (Arch) — one apt/pacman install | Binaries link `libgcc_s` and `libwinpthread` unless told not to; the SEH unwinder is MinGW's rather than MSVC's; DirectX bindings are less exercised (not used — §2.1) |
| `x86_64-pc-windows-msvc` | `lld-link` via [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin) | The MSVC CRT and Windows SDK headers, fetched from Microsoft's servers by `xwin` on first use (~1.5 GB, licence-accepted by flag) | A download step in CI that depends on Microsoft keeping those URLs stable, and a licence the runner accepts on the project's behalf |
**Decision: GNU.** It is a package install, it is what `rustup target add` supports out of the
box, and every crate in the dependency graph that carries a C component (`ring`, `libsqlite3-sys`,
`zstd-sys` if present) builds against MinGW today. The MSVC route produces a marginally more
conventional binary — the same CRT every other Windows application links — and costs a
1.5 GB fetch of Microsoft-licensed headers on every cold CI run. That is the wrong trade for a
channel whose users are, for now, the author.
Static-link the MinGW runtime so the installer carries one file rather than three. The
configuration lives in the container as environment variables rather than in a `.cargo/config.toml`
— that file is untracked here on purpose, and the Android image sets its linkers the same way:
```sh
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc-posix
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-args=-static-libgcc -C link-args=-static-libstdc++"
```
**Corrected.** The first draft added `-Wl,--whole-archive -lwinpthread` "so nothing imports
`libwinpthread-1.dll`". That flag breaks the link: forcing the whole archive drags in unused
winpthread objects whose kernel32 and msvcrt references land after those libraries on the link
line, and the build dies on a hundred undefined `__imp_` symbols. It was also unnecessary —
rustc's windows-gnu target links its own winpthread in self-contained mode, and the built
executable imports no MinGW library at all (§10). `-posix` is stated because Debian's bare
`x86_64-w64-mingw32-gcc` is an alternatives symlink to either thread model.
Two more things the container needs that the draft did not name: a **host** `gcc`, because build
scripts and proc-macros compile for Linux whatever the target and the very first one fails with
"linker `cc` not found" without it; and **Wine 10**, because rustc's std imports
`bcryptprimitives.dll` for its random source and Debian bookworm's Wine 8.0 does not have it —
the smoke test dies at load with `c0000135` before the first instruction. So the image is
`debian:trixie-slim`, which also ships Node 20 natively.
### 2.1 What the binary reaches at runtime
Nothing the installer has to carry. wgpu opens **Vulkan** (`dr_gpu::new_shared`, D1 — Vulkan on
both targets, and the shared-device path permits nothing else), and on Windows the Vulkan loader
`vulkan-1.dll` is installed by every GPU vendor's driver. Slint's femtovg renderer finds system
fonts through `fontdb`, so the `fontconfig` the Linux CI job installs has no Windows counterpart.
There is no `libxkbcommon`, no display-server library: `winit` speaks Win32 directly.
**DirectX 12 is deliberately not enabled.** wgpu supports it and on Windows would be the
conventional choice, but the develop pipeline's compute shaders are written once for Vulkan
(NFR-PORT-2) and validated on two Vulkan drivers already; a third backend is a third set of
driver behaviours to characterise (NFR-R1's tolerance argument), and no Windows machine that can
run this application lacks a Vulkan ICD. The same reasoning that keeps GL out of `new_shared`
keeps DX12 out here. It is one flag away if that turns out to be wrong.
### 2.2 Where it builds
The same shape as the Android leg: a job container built from a Dockerfile in the tree and pushed
to the Gitea registry, tagged by the tree id of its directory so an unrelated push reuses it
([`android-image.yml`](../.gitea/workflows/android-image.yml) already does this and the comment
there explains why).
```
docker/windows/
Dockerfile debian:trixie + rustup (1.92.0, target x86_64-pc-windows-gnu) + gcc + gcc-mingw-w64-x86-64 + nsis + wine
build.sh run a command in the container; caches registry, target and the Wine prefix
package.sh the LFS guard, staging, then makensis
```
`makensis` is a Linux binary; NSIS has always been buildable and runnable on POSIX hosts, and
Debian ships it as `nsis`. Wine is in the image for §6, not for the build. `osslsigncode` is not
in it until there is a certificate to give it (§5.4).
---
## 3. What the tree has to change
Read from the source, not run. Everything here is `dr-plat` or the thin layer above it — nothing
in `core/` is touched, which is NFR-PORT-1 holding.
### 3.1 Already handled
- **`volumes.rs`** — card detection reads `/proc/mounts` and `/sys/block` under
`cfg(target_os = "linux")` and returns an empty list elsewhere. Windows gets no card detection
in this pass; the import flow's path picker still works. (A `GetDriveType`/`DRIVE_REMOVABLE`
implementation is a screen of code and a follow-up.)
- **`display.rs`** — the X11 and Wayland colour-profile readers are `cfg(all(unix, not(android)))`;
the fallback is FR-DSP-8's stated one. Windows ICC profiles via `GetICMProfile` are a follow-up
for the same reason.
- **`desktop_client.rs`** — the Nextcloud desktop client's Unix socket is `cfg(unix)`. On Windows
the client listens on a named pipe (`\\.\pipe\...`); until that is implemented FR-NC-6c's
integration is absent and the app behaves as it does on a Linux machine with no client running.
- **`secrets.rs`** — has a `PlatformSecretStore` for "any platform without an implementation"
that returns `SecretError::Unavailable` on every call. It is loud on purpose, so a Windows build
made with no further change *compiles*, starts, and fails at sign-in with a clear message. §3.2
is what turns that into a working store.
- **`keyring`, `x11rb`, `wayland-*`** are target-scoped dependencies already, so the Linux-only
crates are not even compiled.
### 3.2 Required before the installer is worth shipping
Ordered by what blocks a first sign-in. **All four are done**; each item says how.
1. **Secret store.** *Done.* `keyring` 4's `v1` feature set — the one the workspace already
asks for — includes `windows-native-keyring-store`, so the Credential Manager backend needed
no new feature name, only the crate as a `cfg(windows)` target dependency and the existing
Secret Service implementation's `cfg` widened to include Windows. One implementation over
both, because `keyring::Entry` is the same API over either; the only difference is that
`is_available`'s probe always succeeds on Windows, which is correct — Credential Manager is
always present, so FR-NC-2's degraded mode does not arise.
2. **Paths.** *Done* — [`platform/dr-plat/src/dirs.rs`](../platform/dr-plat/src/dirs.rs). FR-PLAT-LIN-1
says XDG, and the code said it in five places by reading `XDG_*_HOME` and falling back to
`$HOME/.local/...`. On Windows `HOME` is normally unset, so every one of these degraded to a
relative path from the working directory — which for a Start Menu launch is
`C:\Windows\System32`. Now one function per kind of directory in `dr-plat`, with the Windows
branch reading `%APPDATA%` (config; roams) and `%LOCALAPPDATA%` (data, cache, state; does
not), and the five call sites using it. The Android overrides (`set_state_dir`,
`set_data_dir`) stay where they were; only the fallback behind them moved. Both platforms'
rules are unit-tested on either host, and the Windows one was confirmed by running the
application under Wine: the log landed in `AppData\Local\darkroom\state` and nothing was
written anywhere else.
| Kind | Linux today | Windows |
|---|---|---|
| config (`settings.json`, accounts) | `$XDG_CONFIG_HOME/darkroom` — `dr_sync::config_dir`, `settings_store.rs` | `%APPDATA%\darkroom` |
| data (catalog, thumbnails, faces) | `$XDG_DATA_HOME/darkroom` — `library::data_root` | `%LOCALAPPDATA%\darkroom` |
| state (crash reports, diagnostics) | `$XDG_STATE_HOME/darkroom` — `state.rs`, `crash.rs` | `%LOCALAPPDATA%\darkroom\state` |
FR-PLAT-WIN-1 states this as the requirement. The catalog and thumbnail *formats* do not change,
so a library directory copied from a Linux machine opens.
3. **Face models.** *Done.* `library::system_face_models_dirs` walked `$XDG_DATA_DIRS`, which
does not exist on Windows. The rule moved to `dr_plat::system_data_dirs`: the installer puts
the models beside the executable (§5), so the Windows branch returns the executable's own
directory. The user-directory lookups above it are unchanged, so a hand-placed pair still
outranks the installed one, exactly as on Linux.
4. **Opening the sign-in URL.** *Done.* `launch_ui.rs` shelled out to `xdg-open`. The Windows
branch runs `rundll32 url.dll,FileProtocolHandler <url>`, which is `ShellExecute` on the URL
and needs no crate — chosen over `cmd /C start`, whose quoting of `&` in a query string is a
known trap, and over the `open` crate, which would be a dependency for one line. Android has
its own Intent path already, so this is the third branch of a function that already had two.
*Not verified*: Wine has no browser to open.
5. **`std::os::unix` uses outside a `cfg`.** `diagnostics.rs` and `presets.rs` use
`PermissionsExt` for mode bits on written files. Most are inside `#[cfg(unix)]` blocks already;
the first cross-compile will name any that are not, and the fix is a `cfg` rather than a
Windows ACL equivalent — the files in question are the user's own.
6. **The executable's identity.** *Done.* Windows takes the icon and the version block from a
resource compiled into the `.exe`, not from a `.desktop` file.
[`apps/darkroom-desktop/build.rs`](../apps/darkroom-desktop/build.rs) uses `winresource`
(which invokes MinGW's `windres` when cross-compiling) to embed
[`ui/dr-ui/ui/app-icon.png`](../ui/dr-ui/ui/app-icon.png) — wrapped into an `.ico` in
`OUT_DIR` at build time, since an ICO entry may be a PNG, so no generated binary is committed —
plus the version from `CARGO_PKG_VERSION` and the product name. The script returns before
touching the crate on every other target, and `winresource` is an unconditional
build-dependency because **a `cfg(windows)` on a build-dependency is evaluated against the
host**, which is Linux. This is the fifth place the identifier lives, and
[`tools/set-version.sh`](../tools/set-version.sh) does not need to learn it: the resource
reads the version cargo already knows. The same commit made the release binary a GUI-subsystem
executable (`windows_subsystem = "windows"`), or Windows keeps a console window open behind
the application.
Everything in this list is `cfg(windows)` code in `dr-plat` or a call-site switch in `dr-ui`, and
none of it touches the image core, the catalog schema, or the edit pipeline. That is the NFR-PORT-3
test, and it should be stated in the commit that closes the list whether it passed.
**What the first cross-compile actually found** (§9 step 2): nothing in this list blocked the
link. The whole graph compiled; the only warnings were two constants — `SERVICE` in `secrets.rs`
and `TIMEOUT` in `desktop_client.rs` — left unused by the `cfg`s that already shadow their users,
now guarded the same way. Items 1–4 are still open, and the binary starts without them; it just
cannot sign in.
### 3.3 Explicitly not in this pass
- **MIME/file-type registration** — FR-PLAT-LIN-1's `.desktop` MIME entries have a registry
equivalent (`HKCU\Software\Classes\.cr2` etc.). Not until the application opens a file from the
command line usefully, which `main.rs` accepts but the launch flow does not yet act on.
- **High-DPI declaration** — winit sets per-monitor-v2 awareness through its manifest by default.
Verified in winit's source, not on a monitor; if text is blurry on a 150% display this is the
first suspect.
- **Card detection, ICC profiles, the desktop-client pipe** — §3.1's three follow-ups.
- **A GL or DX12 fallback** — §2.1. A machine without Vulkan gets the library and no develop
path, which is what it gets on Linux too.
---
## 4. The build script
`docker/windows/build.sh`, in the shape of the Android one and with the same rules:
```sh
cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
```
Release only, with `CARGO_TARGET_DIR` inside the workspace so the CI cache key
(`windows-${{ hashFiles('**/Cargo.lock') }}`) covers it. The whole workspace is *not* built for
the target: `darkroom-android` cannot be, and the examples that need a display or a catalog on
disk have nothing to run against. `cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop`
is worth running in the same job, because the `cfg(windows)` branches from §3 are otherwise
never linted — the Linux job cannot see them.
Not `cargo test --target x86_64-pc-windows-gnu`: the test binaries would be Windows executables,
and running them means Wine. §6 does that for exactly one binary, deliberately.
---
## 5. The installer
[`packaging/windows/darkroom.nsi`](../packaging/windows/darkroom.nsi), compiled by `makensis` on
the runner into `DarkRoom-<version>-x86_64-setup.exe`. `package.sh` passes the version in
(`/DVERSION=…`, from `tools/set-version.sh`'s single source, the workspace `Cargo.toml`) and refuses
to run if any `models/face/*.onnx` is smaller than 100 KB — the LFS-pointer guard every other
packager carries, for the reason [distribution.md §1](distribution.md) gives.
### 5.1 Per-user, not per-machine
Install to `$LOCALAPPDATA\Programs\DarkRoom`, register the uninstaller under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom`, `RequestExecutionLevel user`.
No UAC prompt, no `Program Files`, no writes outside the user's profile. This is the shape VS Code's
"User Installer" and most Electron applications use, and it is right for this project for two
reasons: an unsigned installer that also asks for administrator rights is the most alarming thing
Windows can show a user (§5.4), and a per-user install means the application's own data directories
(§3.2) and its binaries are governed by the same account, which is what NFR-SEC-5's
"the user's own hardware" means on a shared machine.
### 5.2 What it puts on disk
```
$LOCALAPPDATA\Programs\DarkRoom\
darkroom.exe
models\
scrfd_500m_640.onnx scrfd_2.5g_640.onnx scrfd_10g_640.onnx arcface_mbf_b1.onnx
yolo26s-sem-ade20k.onnx yolo26s-sem-ade20k.classes.json categories.txt
LICENSE
uninstall.exe
```
Plus a Start Menu shortcut, and nothing on the desktop unless the user ticks it. The models are
the same seven files the APK bundles and the PKGBUILD installs; `models\` beside the executable is
where §3.2's lookup finds them. **No `LICENSE` yet**: the repository has no licence file at its
root (the Arch package points at the system's shared GPL text), so the installer has no licence
page until one is added — a one-file change, and the `.nsi` says where the page then goes. The face weights carry the research-only grant that
[faces.md §2](faces.md) records, and this channel changes nothing about that: the installer is
for the author's own machines until §2.2a's caveat is resolved, exactly as the APK is.
### 5.3 Uninstall
Removes the install directory, the shortcut and the registry key. **Does not touch
`%LOCALAPPDATA%\darkroom` or `%APPDATA%\darkroom`** — the catalog, the thumbnails, the face
index, the settings. An uninstaller that deletes a library index the user spent two hours building
is the kind of destructive default FR-CULL-12 and NFR-SEC-5's "disabling deletes nothing" both
argue against. The uninstaller says so on its one page, and names the two directories so a user who
does want them gone knows where they are.
### 5.4 Signing, and the warning that results from not doing it
An unsigned installer triggers SmartScreen's "Windows protected your PC" interstitial, dismissable
through "More info → Run anyway". Signing needs an Authenticode certificate, which is paid and
identity-verified; from Linux the signing itself is `osslsigncode`, which is why it is in the
container image, but there is no certificate to give it. **This spec ships unsigned** and the
release notes say what the interstitial looks like. An OV certificate is a cost decision to make
if this channel ever has a user who is not the author; an EV one buys instant reputation and costs
a hardware token. Neither is a build problem.
The APK went through the same sequence — [android-signing.md](android-signing.md) records a
debug-signed build becoming a release-signed one when it mattered — and this channel should be
allowed to do the same.
### 5.5 What NSIS is chosen over
WiX produces an MSI, which is what enterprise deployment tooling wants and what nobody deploying
a photo editor to their own laptop cares about; its Linux story is `wixl` from msitools, which is
real but thinly used. Inno Setup runs only under Wine. NSIS is scriptable in plain text, builds
natively on Linux, produces a single self-contained `.exe`, and the script for §5.2 is under a
hundred lines. It is the conventional answer for exactly this situation.
One choice inside NSIS: `Target amd64-unicode`, a 64-bit installer rather than the default 32-bit
stub. The application is x86_64 only so nothing is lost, and it is what lets §6's install test run
under a 64-bit-only Wine — the 32-bit stub needs an i386 multiarch Wine and dies loading the WoW64
`ntdll` without one.
---
## 6. Verifying without Windows
This is the part to be honest about. The runner has no Windows, no GPU it can hand to a Windows
process, and no display. What *can* be checked, in increasing cost and decreasing certainty:
| Check | How | What it proves |
|---|---|---|
| **It links** | the build succeeds | Every `cfg(windows)` branch compiles; no `unix`-only symbol leaked past a `cfg` |
| **It is a Windows executable** | `file darkroom.exe` reports PE32+; `x86_64-w64-mingw32-objdump -p` lists the DLLs it imports and none are MinGW's | The static-runtime flags in §2 held |
| **It starts** | `wine64 darkroom.exe --version` exits 0 and prints the version | The CRT, the resource block and `main` are sound; paths in §3.2 resolve (Wine sets `LOCALAPPDATA`) |
| **The installer runs** | `wine64 DarkRoom-setup.exe /S` then the install directory exists with the eight files, and `wine64 uninstall.exe /S` removes it | The NSIS script's file list, sections and uninstaller are right |
| **It draws a window** | `xvfb-run wine64 darkroom.exe` with `SLINT_WGPU_CPU` and a lavapipe ICD exposed through `winevulkan` | That Slint's winit backend initialises on Win32 — and this is where the chain gets long enough that a failure says more about Wine than about DarkRoom |
The first four are the CI gate. The fifth is worth trying once by hand and not putting in CI:
it needs `winevulkan` to find a host ICD, `xvfb`, and a Wine prefix warmed up in the container,
and every one of those is a moving part that has nothing to do with whether the application works
on Windows.
`--version` exists for this — a smoke test needs an exit that opens no window and touches no
directory — and it is answered before the logger and the crash hook install, so it proves the CRT
and the resource block and nothing above them.
**One more row the table missed:** the Start Menu shortcut. `CreateShortcut` is `IShellLink`,
which does nothing under a headless Wine while the `CreateDirectory` beside it succeeds, so an
installer that installs and uninstalls cleanly here can still have a broken shortcut. That row is
on Windows only.
**What none of this proves:** that wgpu opens a Vulkan device on a real driver, that a 6000-px
render completes, that fonts are found, that the secret store round-trips. Those are a person with
a Windows machine, once per release, until there is a Windows runner — and a self-hosted Windows
act_runner is how that would be done, not a cloud service. The release notes for the first build
say which of these were checked and on what.
---
## 7. The CI job
A fourth leg of [`build-and-test.yml`](../.gitea/workflows/build-and-test.yml), beside desktop,
Android and traceability:
```yaml
windows-image:
uses: ./.gitea/workflows/windows-image.yml # same shape as android-image.yml
windows:
runs-on: linux/amd64
name: Windows (x86_64, cross)
needs: windows-image
container:
image: gitea.tourolle.paris/dtourolle/darkroom-windows:latest
steps:
- checkout, LFS pull # copied from the desktop leg
- cache: ~/.cargo, target # key: windows-${{ hashFiles('**/Cargo.lock') }}
- docker/windows/build.sh # cargo build + clippy, --target x86_64-pc-windows-gnu
- smoke: file, objdump, wine64 --version # §6 rows 1–3
- docker/windows/package.sh # LFS guard, makensis
- smoke: wine64 setup.exe /S; ls; uninstall # §6 row 4
- upload artefact: DarkRoom-*-setup.exe # on tags only, like the APK
```
Same gotchas as the Android leg, which its comments already record: the host has no Node, so the
checkout is plain `git`; workflow inputs arrive as strings; the image build needs the host Docker
daemon and runs outside a container. None of that is new.
**Cost.** A cold build of the whole graph for a second target is roughly the desktop leg again —
tract, Slint's compiler, wgpu — so with the cargo cache warm it is minutes and cold it is the
better part of half an hour. Worth noting because the runner is one machine and the legs run in
parallel on it; if it starts starving the desktop leg, `needs: desktop` serialises them.
---
## 8. Requirements
Three, added to [requirements.md §3.8](requirements.md) under a `#### Windows` heading beside the
Linux ones. Phrased to be testable, and each one is something §3 or §5 would otherwise leave as a
convention.
**FR-PLAT-WIN-1 — Known folders.** Configuration under `%APPDATA%\darkroom`; data, cache and
state under `%LOCALAPPDATA%\darkroom`. No file under the user's profile root and nothing relative
to the working directory. The directory *layout* beneath those roots is the same as under XDG, so
a library directory moves between platforms unchanged.
**FR-PLAT-WIN-2 — Installer.** A per-user installer that needs no elevation, registers an
uninstaller, and whose uninstaller removes what the installer wrote and nothing the application
wrote. Models are installed beside the executable and found there last, after the user's own
directories.
**FR-PLAT-WIN-3 — Built from Linux.** The Windows binary and its installer are produced by the
Linux CI from the same commit as every other channel, with no Windows machine in the build.
Verification on Windows is a release step, recorded per release, not a build step.
NFR-COMPAT-2's channel table in [distribution.md §1](distribution.md) gains a row. NFR-PORT-3 gets
its first real test, and the commit that closes §3.2 records the answer.
---
## 9. Order
1. `--version` in `main.rs`, and the `.cargo/config.toml` target block. Trivial, and the smoke
test in §6 needs both before anything else can be measured.
2. `rustup target add x86_64-pc-windows-gnu`, `pacman -S mingw-w64-gcc`, and a first
`cargo build --target …` on the developer machine — **before the container exists**, because
the list in §3.2 is a reading of the source and the compiler's list will be longer. Fix the
`cfg` fallout as it appears. This is the afternoon that decides whether §1's optimism holds.
3. §3.2 items 1–4, each its own commit, each stating which NFR-PORT interface it implemented.
4. §3.2 item 6 — the resource block — and the NSIS script; `makensis` by hand; `wine64 setup.exe /S`
by hand. Now there is an artefact.
5. The container, the image workflow, the CI leg. Only after 4 works locally, for the same reason
the Android image was reproduced from the tree after it had lived on one laptop.
6. A build on a real Windows machine, and a note in the release saying what was checked.
Steps 1–2 are cheap and either confirm this document or replace §3.2 with the true list. Nothing
past step 2 should be started on the strength of this document alone.
---
## 10. Report · 2026-09-12
Steps 1, 2 and 4 run, in the container rather than on the developer machine, because the
container was the cheaper way to get a pinned MinGW and a Wine that could be thrown away.
| §6 row | Result |
|---|---|
| It links | Yes, first attempt once the link flags were right. 115 MB, `PE32+ … (GUI)`. Two dead-code warnings, both `cfg`-shadowed constants, fixed. |
| It is a Windows executable | 26 imports, all Windows system DLLs. No MinGW runtime. `.rsrc` carries `PRODUCTVERSION 0,12,0,0`, `ProductName DarkRoom`, the icon. |
| It starts | `wine darkroom-desktop.exe --version` → `darkroom-desktop 0.12.0`, exit 0, 0.1 s. |
| The installer runs | `makensis` → 105 MB. `/S` installs the exe and seven models to `AppData\Local\Programs\DarkRoom`, writes the `HKCU` uninstall key; the installed exe runs; `uninstall.exe /S` removes directory and key. Shortcut unverifiable (§6). |
| It draws a window | Not attempted. |
**What the first draft got wrong**, kept in place above with a note rather than rewritten, because
the reasoning that produced each mistake is the thing a reader will otherwise repeat:
1. The `--whole-archive -lwinpthread` link flag (§2) — breaks the link and was never needed.
2. No host C compiler in the image (§2) — build scripts are host binaries.
3. Debian bookworm's Wine (§2) — lacks `bcryptprimitives.dll`, which rustc's std imports.
4. A `cfg(windows)` on the `winresource` build-dependency (§3.2 item 6) — evaluated against the
host, so the crate was silently absent from the cross-build.
And two things it did not know to say: NSIS's default stub is 32-bit (§5.5), and `CreateShortcut`
cannot be verified headless (§6).
**Closed since**, same day: all four §3.2 items (each says how), a `LICENSE` at the repository
root so the installer has its licence page, and §7's CI leg — `windows-image.yml` and the
`windows` job, every step of which was run by hand in the same container first. The Windows
target is also linted now, with `cargo clippy --target x86_64-pc-windows-gnu -- -D warnings` in
that job, which is the only place the `cfg(windows)` branches are ever compiled by CI.
**What the first real Windows run has to check**, in order, because Wine cannot: that a Vulkan
device opens on a real driver and a render completes; that fonts are found; that Credential
Manager round-trips a sign-in and the browser opens for Login Flow v2; that the Start Menu
shortcut exists; and that text is sharp on a scaled display (§3.3). The release notes for the
first build should say which of these were checked and on what machine.
+1 -1
View File
@@ -4,7 +4,7 @@
# makes `makepkg -si` in this directory install what you are actually working
# on. Swap `source` for a tagged tarball when there is something to release.
pkgname=darkroom
pkgver=0.12.0
pkgver=0.12.1
# Back to 1 with the version: a new pkgver is a new archive name, so there is
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
pkgrel=1
+133
View File
@@ -0,0 +1,133 @@
; DarkRoom — Windows installer
;
; Built by docker/windows/package.sh with makensis on Linux; see docs/windows.md
; §5 for every decision below. Nothing here is Windows-specific to *write* —
; NSIS has always compiled on POSIX hosts.
;
; makensis -DVERSION=0.12.0 -DSTAGE=/path/to/staging -DOUT=/path/to/setup.exe darkroom.nsi
;
; STAGE holds exactly what §5.2 installs: darkroom.exe, models\ and LICENSE.
; package.sh assembles it and applies the LFS-pointer guard before this runs.
; A 64-bit installer, not NSIS's default 32-bit stub. The application is
; x86_64 only, so nothing is lost; and it means the smoke test runs under a
; 64-bit-only Wine rather than needing an i386 multiarch: the 32-bit stub
; cannot load the WoW64 ntdll there and dies before showing a page.
Target amd64-unicode
Unicode true
SetCompressor /SOLID lzma
!ifndef VERSION
!error "pass -DVERSION=x.y.z"
!endif
!ifndef STAGE
!error "pass -DSTAGE=<staging directory>"
!endif
!ifndef OUT
!error "pass -DOUT=<installer path>"
!endif
!define NAME "DarkRoom"
!define PUBLISHER "Duncan Tourolle"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${NAME}"
Name "${NAME} ${VERSION}"
OutFile "${OUT}"
; Per-user, no elevation (docs/windows.md §5.1). An unsigned installer that
; also asks for administrator rights is the most alarming thing Windows can
; show, and a per-user install keeps the binaries under the same account as
; the library the application builds.
RequestExecutionLevel user
InstallDir "$LOCALAPPDATA\Programs\${NAME}"
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation"
; The resource block the executable itself carries (`winresource` in
; darkroom-desktop's build.rs) is what Explorer shows for the application;
; this is what it shows for the installer.
VIProductVersion "${VERSION}.0"
VIAddVersionKey "ProductName" "${NAME}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "CompanyName" "${PUBLISHER}"
VIAddVersionKey "LegalCopyright" "GPL-3.0-or-later"
VIAddVersionKey "FileDescription" "${NAME} installer"
!include "MUI2.nsh"
!define MUI_ABORTWARNING
; The GPL, shown rather than linked, for the same reason faces.md §2.2 gives
; for the model grant: the moment it can still change a decision.
!insertmacro MUI_PAGE_LICENSE "${STAGE}\LICENSE"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Section "DarkRoom" SecMain
SectionIn RO
SetOutPath "$INSTDIR"
File "${STAGE}\darkroom.exe"
File "${STAGE}\LICENSE"
; The seven model files (four face, three scene), beside the executable,
; which is where `library::system_face_models_dirs` looks on Windows —
; last, after the user's own directories, exactly as /usr/share is on Linux.
SetOutPath "$INSTDIR\models"
File /r "${STAGE}\models\*"
WriteUninstaller "$INSTDIR\uninstall.exe"
; Add/Remove Programs. HKCU, to match the per-user install.
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${NAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${PUBLISHER}"
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\darkroom.exe"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
WriteRegDWORD HKCU "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKCU "${UNINST_KEY}" "NoRepair" 1
CreateDirectory "$SMPROGRAMS\${NAME}"
CreateShortcut "$SMPROGRAMS\${NAME}\${NAME}.lnk" "$INSTDIR\darkroom.exe"
CreateShortcut "$SMPROGRAMS\${NAME}\Uninstall ${NAME}.lnk" "$INSTDIR\uninstall.exe"
SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${NAME}.lnk" "$INSTDIR\darkroom.exe"
SectionEnd
; Off by default: a desktop icon is the user's to ask for.
Function .onInit
SectionSetFlags ${SecDesktop} 0
FunctionEnd
Section "Uninstall"
; What the installer wrote, and nothing the application wrote
; (docs/windows.md §5.3): %LOCALAPPDATA%\darkroom and %APPDATA%\darkroom —
; the catalog, the thumbnails, the face index, the settings — stay. An
; uninstaller that deletes a library index the user spent two hours
; building is the destructive default this project argues against
; everywhere else, so the confirm page says where those directories are.
Delete "$INSTDIR\darkroom.exe"
Delete "$INSTDIR\LICENSE"
Delete "$INSTDIR\uninstall.exe"
RMDir /r "$INSTDIR\models"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\${NAME}\${NAME}.lnk"
Delete "$SMPROGRAMS\${NAME}\Uninstall ${NAME}.lnk"
RMDir "$SMPROGRAMS\${NAME}"
Delete "$DESKTOP\${NAME}.lnk"
DeleteRegKey HKCU "${UNINST_KEY}"
SectionEnd
Function un.onInit
MessageBox MB_OKCANCEL|MB_ICONINFORMATION \
"This removes the ${NAME} program.$\r$\n$\r$\nYour library index and settings are kept, in:$\r$\n $LOCALAPPDATA\darkroom$\r$\n $APPDATA\darkroom" \
/SD IDOK IDOK done
Abort
done:
FunctionEnd
+5
View File
@@ -24,6 +24,11 @@ x11rb.workspace = true
wayland-client.workspace = true
wayland-protocols.workspace = true
# The same crate on Windows: its `v1` features include the Credential Manager
# backend, and `secrets.rs` is one implementation over both.
[target.'cfg(windows)'.dependencies]
keyring.workspace = true
[target.'cfg(target_os = "android")'.dependencies]
android-native-keyring-store.workspace = true
keyring-core.workspace = true
+1 -6
View File
@@ -106,12 +106,7 @@ pub fn state_dir() -> PathBuf {
if let Some(d) = STATE_DIR.get() {
return d.clone();
}
std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state")
})
.join("darkroom")
crate::dirs::base_dir(crate::dirs::Base::State)
}
/// Where crash records are written.
+1
View File
@@ -61,6 +61,7 @@
//! [`redact::redact`] is public so the record gets the same treatment as the
//! lines around it.
pub mod bundle;
pub mod redact;
use std::fmt::Write as _;
+430
View File
@@ -0,0 +1,430 @@
//! TRACES: NFR-OPS-1 | NFR-SEC-2 | NFR-SEC-4 | NFR-SEC-5
//! The diagnostics bundle: one file, shown before it is written.
//!
//! NFR-OPS-1's second sentence asks for "a one-click diagnostics bundle" of
//! the log, the schema version, the GPU and driver, and the app version —
//! "with an explicit preview-and-consent step before anything leaves the
//! device". The log and the crash records have existed since the sink and the
//! panic hook landed; what did not exist was any way to hand them over that
//! was not `adb pull` and a knowledge of where the state directory is.
//!
//! # What "leaves the device" means here, and why the step is where it is
//!
//! Nothing in this module sends anything. There is no endpoint, no upload,
//! no queue — `crash.rs` says why, and the reason holds: a transport built
//! ahead of the consent is the shape of thing that gets switched on by
//! default. What the bundle does is write **one text file** to a place the
//! user can find, so that *they* can attach it to a message. That is the
//! moment it leaves, and it is theirs.
//!
//! So the consent step guards the write, not a send. [`Bundle::gather`] reads
//! everything into memory and touches no file; [`Bundle::preview`] says what
//! was gathered, how much of it, and what was taken out; and only
//! [`Bundle::write_to`] puts bytes on disk. A user who reads the preview and
//! closes the panel has changed nothing anywhere. The interface has to keep
//! those as two presses, and does.
//!
//! # One file, and text
//!
//! A directory is a thing to zip and a zip is a thing to explain. A single
//! `.txt` opens on every platform the user might be sitting at, pastes into
//! an issue, and can be read *in the preview* as exactly the bytes that will
//! be written — which is what makes the consent honest rather than a summary
//! of something else. The sections are separated by a fence no log line can
//! produce, so a reader can find the seams and a tool could split them.
//!
//! # Redacted again, on the way in
//!
//! Every line here has already been through a redaction: the log at its sink
//! ([`super::redact`]), the crash records when they were composed
//! ([`crate::crash::redact`]). The bundle runs the *blunter* of the two over
//! all of it regardless. The log's own rule keeps file paths, because a path
//! in the log a developer reads over `adb` is context; the bundle is a file
//! meant to be attached to a public report by someone who may not read it
//! first, and a directory listing of their photographs is the thing the
//! preview exists to prevent. Redacting twice costs nothing and makes the
//! promise in the preview a property of this module rather than of the
//! modules upstream having done their part.
//!
//! NFR-SEC-5 needs no work here and gets a tag anyway: no face data can reach
//! this bundle because nothing in `dr-plat` can see a catalog, an embedding
//! or a crop. The tag records that the property was checked, not that it was
//! built.
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::crash;
/// The facts the requirement names beside the log, supplied by the caller
/// because none of them is this crate's to know: the running version, the
/// catalog schema, and what the GPU said it was.
#[derive(Debug, Clone, Default)]
pub struct Facts {
pub app_version: String,
pub schema_version: i64,
/// Adapter, backend, and driver, in whatever words the GPU gave.
pub graphics: String,
}
/// One section of the bundle: a named piece of text and how big it is.
#[derive(Debug, Clone)]
pub struct Item {
/// What the section is called in the file — a filename, or `manifest`.
pub name: String,
pub lines: usize,
pub bytes: usize,
text: String,
}
/// Everything the bundle would write, gathered and held in memory.
#[derive(Debug, Clone)]
pub struct Bundle {
when: u64,
items: Vec<Item>,
}
/// The fence between sections. Long enough that no redacted log line is it.
const FENCE: &str = "================================================================";
impl Bundle {
/// Gather the log, its rotated predecessor, and every crash record on
/// disk, redacted. Reads files; writes none.
pub fn gather(facts: &Facts) -> Self {
let log = super::log_path();
let previous = log
.as_deref()
.and_then(Path::parent)
.map(|dir| dir.join(format!("{}.1", super::FILE_NAME)));
Self::from_sources(
facts,
now(),
log.as_deref(),
previous.as_deref(),
&crash::records(),
)
}
/// The gathering, with every path handed in — so a test can build a
/// bundle from a directory of its own without touching the process-wide
/// log.
pub fn from_sources(
facts: &Facts,
when: u64,
log: Option<&Path>,
previous: Option<&Path>,
crashes: &[PathBuf],
) -> Self {
let mut items = vec![manifest(facts, when, log)];
for path in [log, previous].into_iter().flatten() {
if let Some(item) = read_item(path) {
items.push(item);
}
}
for path in crashes {
if let Some(item) = read_item(path) {
items.push(item);
}
}
Self { when, items }
}
pub fn items(&self) -> &[Item] {
&self.items
}
/// The name the file is written under: stamped, so two bundles from one
/// machine do not overwrite each other, and matching the crash records'
/// convention so a directory of both sorts by time.
pub fn file_name(&self) -> String {
format!("darkroom-diagnostics-{}.txt", self.when)
}
/// What the user reads before deciding. Every claim in it is a property
/// of [`Self::render`], which is the same items in the same order.
pub fn preview(&self, destination: &Path) -> String {
let mut out = String::new();
let _ = writeln!(out, "Nothing has been written yet. This is what would be:");
let _ = writeln!(out);
for item in &self.items {
let _ = writeln!(
out,
" {:<32} {:>7} lines {}",
item.name,
item.lines,
size(item.bytes)
);
}
let total: usize = self.items.iter().map(|i| i.bytes).sum();
let _ = writeln!(out);
let _ = writeln!(
out,
"Credentials, tokens, and anything that reads as a file path or a web \
address have been replaced in every line; the names of Rust source \
files in a backtrace are kept. No photograph, thumbnail, face or \
person is in it — nothing here can read the catalog."
);
let _ = writeln!(out);
let _ = writeln!(
out,
"Saving writes one text file, {} ({}), to:\n {}\n\
It is sent nowhere. Attaching it to a report is yours to do.",
self.file_name(),
size(total),
destination.display()
);
out
}
/// The whole bundle as the text that would be written.
pub fn render(&self) -> String {
let mut out = String::new();
for item in &self.items {
let _ = writeln!(out, "{FENCE}");
let _ = writeln!(out, "== {}", item.name);
let _ = writeln!(out, "{FENCE}");
out.push_str(&item.text);
if !item.text.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
out
}
/// Write the bundle into `dir`, creating it, and return the file's path.
/// The only function here that writes.
pub fn write_to(&self, dir: &Path) -> io::Result<PathBuf> {
fs::create_dir_all(dir)?;
let path = dir.join(self.file_name());
fs::write(&path, self.render())?;
Ok(path)
}
}
/// Where a bundle is written when nobody says otherwise.
///
/// The desktop gets the downloads directory — the one place a file can be
/// put that every "attach a file" dialog opens on, and that the user already
/// knows how to find and how to clear. `XDG_DOWNLOAD_DIR` is only in
/// `user-dirs.dirs`, which nothing here parses, so the conventional name
/// under the home directory stands in for it; failing a home, the state
/// directory, which always exists by the time this can be called.
///
/// Android has no downloads directory an app may write without a permission
/// prompt, and the state directory there *is* the externally readable one —
/// `adb pull` and the files app both reach it — so it is used directly.
pub fn default_dir() -> PathBuf {
if cfg!(target_os = "android") {
return crate::state::state_dir();
}
std::env::var_os("HOME")
.map(|home| PathBuf::from(home).join("Downloads"))
.filter(|d| d.is_dir())
.unwrap_or_else(crate::state::state_dir)
}
/// The section that carries what the requirement asks for beside the log.
/// First, so the version is the first thing anyone reading the file sees.
fn manifest(facts: &Facts, when: u64, log: Option<&Path>) -> Item {
let mut text = String::new();
let _ = writeln!(text, "darkroom {}", facts.app_version);
let _ = writeln!(text, "catalog schema {}", facts.schema_version);
let _ = writeln!(text, "graphics {}", facts.graphics);
let _ = writeln!(
text,
"platform {} {}",
std::env::consts::OS,
std::env::consts::ARCH
);
let _ = writeln!(text, "gathered {when} (unix seconds, UTC)");
let _ = writeln!(
text,
"log {}",
match log {
// The basename only: the directory is a path, and the manifest
// is held to the same rule as everything under it.
Some(p) => p.file_name().map_or_else(
|| "present".to_string(),
|n| n.to_string_lossy().into_owned()
),
None => "none — this session logs to the console only".to_string(),
}
);
let _ = writeln!(
text,
"log cap {} bytes per file, {} rotated file kept",
super::MAX_FILE_BYTES,
super::RETAINED_GENERATIONS
);
item("manifest", text)
}
/// A file on disk as a redacted section, or nothing if it cannot be read —
/// an unreadable log is reported in the preview by its absence, which is the
/// truthful thing, rather than by a bundle that fails to build.
fn read_item(path: &Path) -> Option<Item> {
let raw = fs::read(path).ok()?;
let name = path.file_name()?.to_string_lossy().into_owned();
let text = String::from_utf8_lossy(&raw);
let text: String = text
.lines()
.map(crash::redact)
.collect::<Vec<_>>()
.join("\n");
Some(item(&name, text))
}
fn item(name: &str, text: String) -> Item {
Item {
name: name.to_string(),
lines: text.lines().count(),
bytes: text.len(),
text,
}
}
fn size(bytes: usize) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{} KB", bytes / 1024)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn facts() -> Facts {
Facts {
app_version: "0.12.0".into(),
schema_version: 14,
graphics: "Test GPU (VULKAN) driver 1.0".into(),
}
}
fn dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dr-bundle-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
/// The property the consent rests on: the preview describes the file,
/// and building the preview writes nothing.
#[test]
fn gathering_and_previewing_write_nothing() {
let d = dir("preview");
let log = d.join("darkroom.log");
fs::write(&log, "one\ntwo\n").unwrap();
let before = fs::read_dir(&d).unwrap().flatten().count();
let bundle = Bundle::from_sources(&facts(), 1_700_000_000, Some(&log), None, &[]);
let preview = bundle.preview(&d);
assert!(preview.contains("darkroom.log"), "{preview}");
assert!(preview.contains("2 lines"), "{preview}");
assert!(
preview.contains("Nothing has been written yet"),
"{preview}"
);
assert_eq!(
fs::read_dir(&d).unwrap().flatten().count(),
before,
"the preview put a file on disk"
);
}
/// And the write is one file, named as the preview said, holding the
/// sections the preview listed in the order it listed them.
#[test]
fn saving_writes_one_file_with_every_section() {
let d = dir("save");
let log = d.join("darkroom.log");
let previous = d.join("darkroom.log.1");
let crash = d.join("crash-1700000000-1.txt");
fs::write(&log, "current\n").unwrap();
fs::write(&previous, "older\n").unwrap();
fs::write(&crash, "panicked at library.rs:12\n").unwrap();
let bundle = Bundle::from_sources(
&facts(),
1_700_000_001,
Some(&log),
Some(&previous),
&[crash],
);
let out = d.join("out");
let written = bundle.write_to(&out).unwrap();
assert_eq!(
written.file_name().unwrap(),
"darkroom-diagnostics-1700000001.txt"
);
assert_eq!(fs::read_dir(&out).unwrap().flatten().count(), 1);
let text = fs::read_to_string(&written).unwrap();
let names: Vec<&str> = text.lines().filter_map(|l| l.strip_prefix("== ")).collect();
assert_eq!(
names,
[
"manifest",
"darkroom.log",
"darkroom.log.1",
"crash-1700000000-1.txt"
]
);
assert!(text.contains("darkroom 0.12.0"), "{text}");
assert!(text.contains("catalog schema 14"), "{text}");
assert!(text.contains("Test GPU"), "{text}");
assert!(
text.contains("library.rs:12"),
"a backtrace keeps its source names"
);
}
/// The redaction is this module's promise, not an assumption about the
/// files it read: a path or a secret that reached the log unredacted
/// still does not reach the bundle.
#[test]
fn a_path_in_the_log_does_not_reach_the_bundle() {
let d = dir("redact");
let log = d.join("darkroom.log");
fs::write(
&log,
"opened /home/someone/Photos/2024/wedding/IMG_0001.CR3\npassword=hunter2hunter2\n",
)
.unwrap();
let bundle = Bundle::from_sources(&facts(), 1, Some(&log), None, &[]);
let text = bundle.render();
assert!(!text.contains("wedding"), "{text}");
assert!(!text.contains("hunter2"), "{text}");
assert!(
text.contains("opened"),
"the rest of the line survives: {text}"
);
}
/// A session logging to the console only still gets a bundle — the
/// manifest and the crash records — and the manifest says the log is
/// missing rather than the bundle failing to build.
#[test]
fn no_log_is_a_bundle_that_says_so() {
let bundle = Bundle::from_sources(&facts(), 1, None, None, &[]);
assert_eq!(bundle.items().len(), 1);
assert!(bundle.render().contains("console only"));
}
}
+218
View File
@@ -0,0 +1,218 @@
//! TRACES: FR-PLAT-LIN-1 | FR-PLAT-WIN-1 | NFR-PORT-1
//! Where this application's files belong on this platform.
//!
//! One place for the rule, because it was in five. Each site read
//! `XDG_*_HOME` and fell back to `$HOME/.local/...` on its own, which is fine
//! on Linux and wrong everywhere else: Windows sets neither variable, so every
//! one of them degraded to a path relative to the working directory — for a
//! Start Menu launch, `C:\Windows\System32`. The callers keep their own
//! override (`set_state_dir`, `set_data_dir`), which is how Android names its
//! app-private directory; this answers the question those overrides do not.
//!
//! # The rules
//!
//! | | Config | Data | State |
//! |---|---|---|---|
//! | Unix | `$XDG_CONFIG_HOME` | `$XDG_DATA_HOME` | `$XDG_STATE_HOME` |
//! | Unix default | `~/.config` | `~/.local/share` | `~/.local/state` |
//! | Windows | `%APPDATA%` | `%LOCALAPPDATA%` | `%LOCALAPPDATA%`, then `state` |
//! | Windows default | `%USERPROFILE%\AppData\Roaming` | `…\AppData\Local` | `…\AppData\Local` |
//!
//! then `darkroom` under each. Config roams on Windows and the rest does not,
//! which is the same split XDG makes between config and everything else, and
//! the reason `settings.json` is small and the thumbnail store is not.
//!
//! A relative value is ignored rather than resolved: the XDG specification
//! says so, and the alternative is a `darkroom/` directory wherever the app
//! was launched from. With nothing usable set at all the answer is the
//! temporary directory — a container or a service unit with no home — because
//! writing a log into `/tmp` is a poor outcome and refusing to write one, on
//! exactly the machine nobody is sitting in front of, is a worse one.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// Which kind of directory. The distinction is the one every platform makes:
/// what the user edits, what the application builds, and what it records.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Base {
/// Settings and accounts. Small, and the user may back it up or edit it.
Config,
/// Catalogs, thumbnails, models. Large, rebuildable, never edited by hand.
Data,
/// Logs and crash records. Survives a cache sweep; not configuration.
State,
}
/// The directory of this kind on this machine, ending in `darkroom`.
///
/// Not created here: whoever writes into it creates it, so that merely asking
/// leaves nothing behind.
pub fn base_dir(kind: Base) -> PathBuf {
resolve(kind, |name| std::env::var_os(name))
}
/// Directories a package may have installed shared data into, most specific
/// first, each ending in `darkroom`.
///
/// Unix: `$XDG_DATA_DIRS`, defaulting to `/usr/local/share:/usr/share` — what
/// the Arch package and the Flatpak install under. Windows: the directory the
/// executable is in, which is where the installer puts the models
/// (FR-PLAT-WIN-2). Android: none; the APK's copy is unpacked into the user
/// data directory instead, because an asset inside a package is not a path
/// anything can read from.
pub fn system_data_dirs() -> Vec<PathBuf> {
if cfg!(target_os = "android") {
return Vec::new();
}
if cfg!(windows) {
return std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(Path::to_path_buf))
.into_iter()
.collect();
}
let dirs = std::env::var("XDG_DATA_DIRS")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.split(':')
.filter(|d| !d.is_empty())
.map(|d| PathBuf::from(d).join("darkroom"))
.collect()
}
/// The resolution as a function of its inputs rather than of the process
/// environment, so it can be tested without `set_var` racing every other
/// test in the binary — and so both platforms' rules are tested on either.
fn resolve(kind: Base, env: impl Fn(&str) -> Option<OsString>) -> PathBuf {
let base = if cfg!(windows) {
windows_base(kind, &env)
} else {
xdg_base(kind, &env)
};
let dir = base.unwrap_or_else(std::env::temp_dir).join("darkroom");
match (kind, cfg!(windows)) {
// Data and state share `%LOCALAPPDATA%`; state gets its own level so
// a log and a catalog do not sit side by side.
(Base::State, true) => dir.join("state"),
_ => dir,
}
}
fn xdg_base(kind: Base, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let (var, under_home) = match kind {
Base::Config => ("XDG_CONFIG_HOME", ".config"),
Base::Data => ("XDG_DATA_HOME", ".local/share"),
Base::State => ("XDG_STATE_HOME", ".local/state"),
};
absolute(env(var)).or_else(|| absolute(env("HOME")).map(|h| h.join(under_home)))
}
fn windows_base(kind: Base, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let (var, under_profile) = match kind {
Base::Config => ("APPDATA", "AppData\\Roaming"),
Base::Data | Base::State => ("LOCALAPPDATA", "AppData\\Local"),
};
absolute(env(var)).or_else(|| absolute(env("USERPROFILE")).map(|p| p.join(under_profile)))
}
/// A value only if it names an absolute path. `is_absolute` is the host's
/// notion, so a test of the Windows rule on Linux uses Unix-shaped paths;
/// what is tested is the variable and the suffix, which is the part that
/// was wrong.
fn absolute(value: Option<OsString>) -> Option<PathBuf> {
value
.filter(|v| Path::new(v).is_absolute())
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
let map: HashMap<String, OsString> = pairs
.iter()
.map(|(k, v)| (k.to_string(), OsString::from(v)))
.collect();
move |name| map.get(name).cloned()
}
#[test]
fn xdg_honours_each_variable_and_defaults_under_home() {
let e = env(&[("XDG_CONFIG_HOME", "/etc/me"), ("HOME", "/home/someone")]);
assert_eq!(xdg_base(Base::Config, &e), Some(PathBuf::from("/etc/me")));
assert_eq!(
xdg_base(Base::Data, &e),
Some(PathBuf::from("/home/someone/.local/share"))
);
assert_eq!(
xdg_base(Base::State, &e),
Some(PathBuf::from("/home/someone/.local/state"))
);
}
#[test]
fn a_relative_value_is_ignored_rather_than_resolved() {
// The specification requires this, and the failure it prevents is a
// `darkroom/` directory appearing in whatever the working directory
// happened to be — including, on a desktop launcher, `/`.
let e = env(&[("XDG_STATE_HOME", "state"), ("HOME", "/home/someone")]);
assert_eq!(
xdg_base(Base::State, &e),
Some(PathBuf::from("/home/someone/.local/state"))
);
assert_eq!(xdg_base(Base::State, &env(&[("HOME", "")])), None);
}
#[test]
fn windows_splits_config_from_the_rest() {
// Config roams, data and state do not. The variable is what was wrong
// before this module: neither XDG_* nor HOME exists on Windows.
let e = env(&[("APPDATA", "/r"), ("LOCALAPPDATA", "/l")]);
assert_eq!(windows_base(Base::Config, &e), Some(PathBuf::from("/r")));
assert_eq!(windows_base(Base::Data, &e), Some(PathBuf::from("/l")));
assert_eq!(windows_base(Base::State, &e), Some(PathBuf::from("/l")));
}
#[test]
fn windows_falls_back_to_the_profile() {
let e = env(&[("USERPROFILE", "/u")]);
assert_eq!(
windows_base(Base::Config, &e),
Some(PathBuf::from("/u").join("AppData\\Roaming"))
);
assert_eq!(
windows_base(Base::Data, &e),
Some(PathBuf::from("/u").join("AppData\\Local"))
);
}
#[test]
fn nothing_set_is_still_absolute() {
// A container or a service unit: the answer is somewhere writable,
// never a relative path.
let dir = resolve(Base::State, env(&[]));
assert!(dir.is_absolute());
assert!(dir.ends_with("darkroom") || dir.ends_with("state"));
}
#[test]
fn every_kind_ends_in_the_application_name() {
let e = env(&[
("HOME", "/home/someone"),
("APPDATA", "/r"),
("LOCALAPPDATA", "/l"),
]);
for kind in [Base::Config, Base::Data, Base::State] {
let dir = resolve(kind, &e);
assert!(
dir.components().any(|c| c.as_os_str() == "darkroom"),
"{kind:?} resolved to {}",
dir.display()
);
}
}
}
+2
View File
@@ -12,6 +12,7 @@
// arrangement this crate exists to prevent.
pub mod crash;
pub mod diagnostics;
pub mod dirs;
pub mod display;
pub mod input;
pub mod secrets;
@@ -20,6 +21,7 @@ pub mod storage;
pub mod volumes;
pub use diagnostics::{Installed, LogFile};
pub use dirs::{base_dir, system_data_dirs, Base};
pub use display::{
Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason,
ProfileSource,
+22 -10
View File
@@ -97,13 +97,25 @@ pub trait SecretStore: Send + Sync {
}
/// The service name entries are filed under.
///
/// Used by the two stores that have somewhere to file them; the placeholder
/// below has nothing to name, and a Windows build otherwise warns here.
#[cfg(any(all(unix, not(target_os = "android")), windows, target_os = "android"))]
const SERVICE: &str = "DarkRoom";
/// Secret Service implementation (GNOME Keyring, KWallet via ksecretd).
#[cfg(all(unix, not(target_os = "android")))]
/// The `keyring`-backed store: Secret Service on Linux (GNOME Keyring,
/// KWallet via ksecretd), Credential Manager on Windows.
///
/// One implementation for both because `keyring::Entry` is the same API over
/// either, and its `v1` feature set already includes the Windows backend. The
/// difference is in what "unavailable" means: a Linux desktop may have no
/// secrets daemon, which FR-NC-2 treats as a stated degraded mode, while
/// Credential Manager is always present — so on Windows `is_available` is a
/// probe that always succeeds, and that is correct rather than optimistic.
#[cfg(any(all(unix, not(target_os = "android")), windows))]
pub struct PlatformSecretStore;
#[cfg(all(unix, not(target_os = "android")))]
#[cfg(any(all(unix, not(target_os = "android")), windows))]
impl PlatformSecretStore {
pub fn new() -> Self {
Self
@@ -114,14 +126,14 @@ impl PlatformSecretStore {
}
}
#[cfg(all(unix, not(target_os = "android")))]
#[cfg(any(all(unix, not(target_os = "android")), windows))]
impl Default for PlatformSecretStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(all(unix, not(target_os = "android")))]
#[cfg(any(all(unix, not(target_os = "android")), windows))]
impl SecretStore for PlatformSecretStore {
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
Self::entry(secret_ref)?
@@ -156,7 +168,7 @@ impl SecretStore for PlatformSecretStore {
}
}
#[cfg(all(unix, not(target_os = "android")))]
#[cfg(any(all(unix, not(target_os = "android")), windows))]
fn map_err(e: keyring::Error) -> SecretError {
match e {
keyring::Error::NoEntry => SecretError::NotFound,
@@ -261,24 +273,24 @@ fn map_err(e: keyring_core::Error) -> SecretError {
///
/// Failing loudly is deliberate: a silent no-op store would look like it
/// worked and then lose the credential.
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
#[cfg(not(any(all(unix, not(target_os = "android")), windows, target_os = "android")))]
pub struct PlatformSecretStore;
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
#[cfg(not(any(all(unix, not(target_os = "android")), windows, target_os = "android")))]
impl PlatformSecretStore {
pub fn new() -> Self {
Self
}
}
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
#[cfg(not(any(all(unix, not(target_os = "android")), windows, target_os = "android")))]
impl Default for PlatformSecretStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
#[cfg(not(any(all(unix, not(target_os = "android")), windows, target_os = "android")))]
impl SecretStore for PlatformSecretStore {
fn store(&self, _r: &SecretRef, _s: &str) -> Result<(), SecretError> {
Err(SecretError::Unavailable(
+2 -56
View File
@@ -44,8 +44,7 @@
//! reason [`crate::diagnostics`] redacts at the sink rather than trusting call
//! sites: everything written here is readable by anyone holding the device.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::OnceLock;
/// Declared once by the platform entry point; a guess otherwise.
@@ -72,66 +71,13 @@ pub fn state_dir() -> PathBuf {
if let Some(dir) = STATE_DIR.get() {
return dir.clone();
}
xdg_state_dir(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME"))
}
/// The XDG resolution, as a function of its inputs rather than of the process
/// environment, so it can be tested without `set_var` racing every other test
/// in the binary.
///
/// Relative values are ignored rather than resolved against the working
/// directory: the base-directory specification says so explicitly, and the
/// alternative is a `darkroom/` directory appearing wherever the app was
/// launched from.
fn xdg_state_dir(xdg_state_home: Option<OsString>, home: Option<OsString>) -> PathBuf {
xdg_state_home
.filter(|value| Path::new(value).is_absolute())
.map(PathBuf::from)
.or_else(|| {
home.filter(|value| Path::new(value).is_absolute())
.map(|value| PathBuf::from(value).join(".local/state"))
})
// A container or a systemd unit with neither variable set. Writing a
// log into `/tmp` is a poor outcome; refusing to log at all, on the
// one kind of machine nobody is sitting in front of, is a worse one.
.unwrap_or_else(std::env::temp_dir)
.join("darkroom")
crate::dirs::base_dir(crate::dirs::Base::State)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_log_goes_under_the_state_directory_the_user_named() {
// NFR-OPS-1 says "the XDG state directory", and honouring
// $XDG_STATE_HOME is the whole of what that means to a user who has
// moved theirs.
let dir = xdg_state_dir(Some("/var/lib/dr".into()), Some("/home/someone".into()));
assert_eq!(dir, PathBuf::from("/var/lib/dr/darkroom"));
}
#[test]
fn without_the_variable_it_is_the_specifications_default() {
let dir = xdg_state_dir(None, Some("/home/someone".into()));
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
}
#[test]
fn a_relative_value_is_ignored_rather_than_resolved() {
// The specification requires this, and the failure it prevents is a
// `darkroom/` directory appearing in whatever the working directory
// happened to be — including, on a desktop launcher, `/`.
let dir = xdg_state_dir(Some("state".into()), Some("/home/someone".into()));
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
let dir = xdg_state_dir(Some("".into()), Some("".into()));
assert!(
dir.is_absolute(),
"an empty HOME must not produce a relative state directory"
);
}
#[test]
fn a_declared_directory_is_declared_once() {
// The property the entry points rely on: two callers cannot split the
+1
View File
@@ -29,6 +29,7 @@ tokio.workspace = true
reqwest.workspace = true
dr-plat.workspace = true
dr-sync.workspace = true
dr-xmp.workspace = true
dr-sync-folder.workspace = true
dr-sync-nextcloud.workspace = true
dr-export.workspace = true
+557 -35
View File
@@ -52,6 +52,13 @@ pub struct SyncReport {
pub thumbnails_adopted: usize,
pub catalog_uploaded: bool,
pub catalog_merged: bool,
/// The copy on the server was damaged beyond reading, and ours replaced it.
///
/// Counted because it is the one outcome here that *destroys* something. A
/// sync that silently overwrote a peer's collections would be the bug this
/// whole path is written to avoid, so when it does happen the report has to
/// be able to say so rather than looking like an ordinary push.
pub catalog_replaced: bool,
pub collections_gained: usize,
/// Membership rows the merge brought in (FR-CAT-7).
///
@@ -88,6 +95,7 @@ impl SyncReport {
|| self.shards_downloaded > 0
|| self.catalog_uploaded
|| self.catalog_merged
|| self.catalog_replaced
|| self.face_shards_uploaded > 0
|| self.face_shards_downloaded > 0
|| self.place_adopted
@@ -583,11 +591,28 @@ fn legacy_upload_of_ours(store: &ThumbStore, id: u32, remote_size: u64) -> bool
.unwrap_or(false)
}
/// Exchange the catalog, for its collections.
/// Exchange the catalog, for its collections and its people.
///
/// Only collections merge — see [`dr_catalog::sync`]. The rest of a catalog
/// describes local state (folder ETags, cache paths, job rows) and importing
/// another device's version would be actively wrong.
/// Only collections, keywords and people merge — see [`dr_catalog::sync`]. The
/// rest of a catalog describes local state (folder ETags, cache paths, job
/// rows) and importing another device's version would be actively wrong.
///
/// # Generations
///
/// TRACES: FR-NC-9 | NFR-R2
/// The server keeps the current copy and the [`GENERATIONS`] before it:
/// `catalog.sqlite`, then `catalog.1.sqlite` (the one it replaced), `.2`,
/// `.3`. A push uploads to a temporary name, rotates, and moves the upload into
/// place — so at no moment is there no current copy, and a copy that turns out
/// damaged has the one before it to fall back on. Rotation is server-side
/// renames; the only transfer is the upload itself.
///
/// This is what a damaged copy used to lack. With one copy and nothing behind
/// it, "the current file will not open" left two answers, both bad: refuse for
/// ever, or overwrite with ours and lose whatever another device had added
/// since. Now it has a third — merge from the newest readable generation, which
/// loses nothing — and the damaged file itself is kept as `.1` by the ordinary
/// rotation rather than by a separate upload.
async fn sync_catalog(
backend: &dyn RemoteBackend,
base: &RemotePath,
@@ -595,7 +620,7 @@ async fn sync_catalog(
scratch: &Path,
report: &mut SyncReport,
) -> Result<(), String> {
let remote_name = "catalog.sqlite";
let remote_name = CATALOG_NAME;
let target = RemotePath::new(format!("{}/{remote_name}", base.as_str()));
// ---- take theirs first -----------------------------------------------
@@ -627,30 +652,48 @@ async fn sync_catalog(
};
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) {
Ok(catalog) => match catalog.merge_remote_catalog(&downloaded) {
Ok(merge) => {
report.catalog_merged = true;
report.collections_gained = merge.inserted + merge.updated;
report.members_gained = merge.members_added;
}
// 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!("not pushing the catalog: opening ours to merge: {e}");
let _ = std::fs::remove_file(&downloaded);
let catalog = match dr_catalog::Catalog::open(catalog_path) {
Ok(c) => c,
Err(e) => {
log::warn!("not pushing the catalog: opening ours to merge: {e}");
return Ok(());
}
};
match merge_downloaded(&catalog, scratch, &bytes, report) {
Ok(()) => {}
// TRACES: FR-NC-9
// Damaged beyond reading, and the whole file arrived — so it is
// not a short download, and no device will read it either. Fall
// back to the generation before it; only with none readable is
// ours the whole truth. See the header for why refusing here for
// ever was the wrong answer.
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
if !arrived_whole(backend, base, remote_name, bytes.len(), &detail).await {
return Ok(());
}
match merge_from_generations(backend, base, &catalog, scratch, report).await {
Some(n) => log::warn!(
"the catalog on the server is damaged ({detail}) and all {} bytes of \
it arrived, so no device can read it; merged from the generation \
before it ({}) instead, and replacing it",
bytes.len(),
generation_name(n)
),
None => log::warn!(
"the catalog on the server is damaged ({detail}) and all {} bytes of \
it arrived, so no device can read it, and no earlier generation is \
readable either; replacing it with this device's copy",
bytes.len()
),
}
report.catalog_replaced = true;
}
// Unreadable is not the same as absent: it may be a newer
// format. Ours must not go over it.
Err(e) => {
log::warn!("not pushing the catalog: merging the server's copy: {e}");
return Ok(());
}
let _ = std::fs::remove_file(&downloaded);
}
}
@@ -666,15 +709,177 @@ async fn sync_catalog(
.map_err(|e| e.to_string())?;
let bytes = std::fs::read(&snapshot).map_err(|e| e.to_string())?;
match backend.put(&target, bytes, None).await {
Ok(_) => report.catalog_uploaded = true,
Err(e) => log::warn!("uploading catalog: {e}"),
}
let _ = std::fs::remove_file(&snapshot);
// To a temporary name first. The upload is the only step that can fail
// half-way, and a half-uploaded *current* copy is exactly the damaged file
// this whole scheme exists to survive. Under its own name a failure leaves
// the current copy untouched and costs one stray file, retried next pass.
let staging = RemotePath::new(format!("{}/{UPLOAD_NAME}", base.as_str()));
let sent = bytes.len() as u64;
if let Err(e) = backend.put(&staging, bytes, None).await {
log::warn!("uploading catalog: {e}");
return Ok(());
}
// TRACES: NFR-R2
// Confirm the server holds what was sent before it becomes the copy every
// other device reads. A chunked upload is assembled server-side, and an
// assembly that went wrong is a file of plausible size that no device can
// open — the one failure the generations exist to survive, and cheaper
// to catch here, on the device that caused it, than on every other one
// after. One listing; the size is what the server can vouch for without
// reading the file back.
match remote_size(backend, base, UPLOAD_NAME).await {
Some(held) if held == sent => {}
Some(held) => {
log::warn!(
"not replacing the catalog: sent {sent} bytes but the server holds {held}; \
the upload is discarded and retried next pass"
);
let _ = backend.delete(&RemoteId::Path(staging), None).await;
return Ok(());
}
None => {
log::warn!(
"not replacing the catalog: the server would not confirm the upload's size; \
it is discarded and retried next pass"
);
let _ = backend.delete(&RemoteId::Path(staging), None).await;
return Ok(());
}
}
// Rotate, then move the upload into place. Every step here is a rename on
// the server, and every destination is empty by the time it is written to
// — a `move_to` will not overwrite, by design — so a failure at any point
// leaves a gap in the generations and never a missing current copy.
if let Err(e) = rotate_generations(backend, base).await {
log::warn!("not replacing the catalog: rotating the earlier copies: {e}");
return Ok(());
}
match backend.move_to(&RemoteId::Path(staging), &target).await {
Ok(()) => report.catalog_uploaded = true,
Err(e) => log::warn!("moving the uploaded catalog into place: {e}"),
}
Ok(())
}
/// The current copy's name on the server.
const CATALOG_NAME: &str = "catalog.sqlite";
/// Where a push lands before it is rotated into place.
const UPLOAD_NAME: &str = "catalog.upload.sqlite";
/// How many earlier copies the server keeps behind the current one.
///
/// Three, because what they are for is surviving one damaged push and the one
/// or two syncs it may take for a device to notice. More would cost nothing in
/// transfer — rotation is renames — but each is a 40 MB file on the account's
/// quota, and the local backups (NFR-R2) are the long-term store.
const GENERATIONS: usize = 3;
/// `catalog.N.sqlite` for `1 <= N <= GENERATIONS`; `.1` is the newest.
fn generation_name(n: usize) -> String {
format!("catalog.{n}.sqlite")
}
/// Merge a downloaded catalog into ours, through a file in scratch.
///
/// Attaching needs a path, and the download is bytes. Written and removed here
/// so the callers — the current copy, and each generation tried after it —
/// cannot disagree about cleanup.
fn merge_downloaded(
catalog: &dr_catalog::Catalog,
scratch: &Path,
bytes: &[u8],
report: &mut SyncReport,
) -> Result<(), dr_catalog::CatalogError> {
let downloaded = scratch.join("catalog-remote.sqlite");
std::fs::write(&downloaded, bytes).map_err(|e| dr_catalog::CatalogError::Io(e.to_string()))?;
let result = catalog.merge_remote_catalog(&downloaded);
let _ = std::fs::remove_file(&downloaded);
let merge = result?;
report.catalog_merged = true;
report.collections_gained += merge.inserted + merge.updated;
report.members_gained += merge.members_added;
Ok(())
}
/// TRACES: FR-NC-9
/// Merge from the newest generation that reads, when the current copy will
/// not. Returns which one, or `None` when none of them does.
///
/// Newest first, and the first readable one wins: a generation is a complete
/// snapshot, so an older one adds nothing a newer one lacks. A generation that
/// is absent, damaged, or from a newer schema is skipped the same way — none of
/// those is a reason to stop looking further back.
async fn merge_from_generations(
backend: &dyn RemoteBackend,
base: &RemotePath,
catalog: &dr_catalog::Catalog,
scratch: &Path,
report: &mut SyncReport,
) -> Option<usize> {
for n in 1..=GENERATIONS {
let path = RemotePath::new(format!("{}/{}", base.as_str(), generation_name(n)));
let bytes = match read_derived(backend, &path).await {
Ok(b) => b,
Err(RemoteError::NotFound(_)) => continue,
Err(e) => {
log::debug!("skipping {}: {e}", generation_name(n));
continue;
}
};
match merge_downloaded(catalog, scratch, &bytes, report) {
Ok(()) => return Some(n),
Err(e) => log::debug!("skipping {}: {e}", generation_name(n)),
}
}
None
}
/// Make room for a new current copy: drop the oldest generation and shift the
/// rest back by one, ending with the current copy as `.1`.
///
/// Oldest first, so that each destination is empty when it is moved into —
/// `move_to` refuses to overwrite, and rightly. A name that is not there is
/// not an error at any step: a library that has synced twice has no `.3` yet.
async fn rotate_generations(backend: &dyn RemoteBackend, base: &RemotePath) -> Result<(), String> {
let at = |name: String| RemotePath::new(format!("{}/{name}", base.as_str()));
match backend
.delete(&RemoteId::Path(at(generation_name(GENERATIONS))), None)
.await
{
Ok(()) | Err(RemoteError::NotFound(_)) => {}
Err(e) => return Err(format!("dropping {}: {e}", generation_name(GENERATIONS))),
}
for n in (1..GENERATIONS).rev() {
match backend
.move_to(
&RemoteId::Path(at(generation_name(n))),
&at(generation_name(n + 1)),
)
.await
{
Ok(()) | Err(RemoteError::NotFound(_)) => {}
Err(e) => return Err(format!("moving {} back: {e}", generation_name(n))),
}
}
match backend
.move_to(
&RemoteId::Path(at(CATALOG_NAME.into())),
&at(generation_name(1)),
)
.await
{
Ok(()) | Err(RemoteError::NotFound(_)) => Ok(()),
Err(e) => Err(format!("setting the current copy back: {e}")),
}
}
/// TRACES: FR-UI-8
/// The place's name inside the derived folder.
///
@@ -851,6 +1056,64 @@ async fn read_derived(
}
}
/// TRACES: FR-NC-9
/// Whether a download that will not open is the server's whole file.
///
/// A truncated download is also unreadable, and on a phone it is the far
/// likelier story — this library's logs are full of aborted bodies and DNS
/// failures. Treating that as damage would let one bad connection discard a
/// catalog the server was holding perfectly well. So the size the server
/// advertises is compared against what actually arrived, and anything short,
/// or any size the listing cannot confirm, is the transport failure it is:
/// the caller must leave the server's copy alone.
async fn arrived_whole(
backend: &dyn RemoteBackend,
base: &RemotePath,
name: &str,
received: usize,
detail: &str,
) -> bool {
let Some(advertised) = remote_size(backend, base, name).await else {
log::warn!(
"not pushing the catalog: the copy on the server will not open ({detail}), \
but its size could not be confirmed, so it may simply have arrived short"
);
return false;
};
if advertised != received as u64 {
log::warn!(
"not pushing the catalog: the copy on the server will not open ({detail}), \
but only {received} of {advertised} bytes arrived — that is a truncated \
download, not a damaged file, so the server's copy is left alone"
);
return false;
}
true
}
/// The size the server says an entry in the derived folder has.
///
/// One listing of a folder that holds a handful of files, rather than a HEAD
/// the backend trait does not offer. `None` covers "the listing failed", "it is
/// not there", and "the size it reports means nothing" alike, and the caller
/// treats all of them as not knowing — which is the answer that declines to
/// overwrite.
///
/// A placeholder is excluded rather than trusted: `RemoteEntry::size` is
/// explicitly not meaningful when `materialised` is false — a suffix-mode stub
/// is one byte and carries no record of what it stands for — so comparing a
/// download against it would be comparing against nothing.
async fn remote_size(backend: &dyn RemoteBackend, base: &RemotePath, name: &str) -> Option<u64> {
backend
.list(base, None)
.await
.ok()?
.into_iter()
.find(|e| e.kind == dr_sync::EntryKind::File && e.path.name() == name)
.filter(|e| e.materialised)
.map(|e| e.size)
}
fn shard_name(client: &str, id: u32) -> String {
format!("shard-{client}-{id:04}.sqlite")
}
@@ -979,6 +1242,19 @@ mod derived_guard_tests {
/// record went up rather than only that one did.
last_put: Arc<std::sync::Mutex<Vec<u8>>>,
caps: dr_sync::Capabilities,
/// The size `list` claims `catalog.sqlite` has, if it lists it at all.
/// This is what says whether a body that will not open is a damaged
/// file or merely a short download.
advertise: Option<u64>,
/// Bodies by file name, for tests that need the current copy and a
/// generation to differ. When empty every read serves `body`; when
/// not, a name absent here reads as `NotFound`.
bodies: std::collections::HashMap<String, Vec<u8>>,
/// Every `move_to`, as (from, to) names, in order.
moves: Arc<std::sync::Mutex<Vec<(String, String)>>>,
/// What `list` claims the staged upload's size is, when a test wants
/// the server to have assembled it wrongly. `None` reports the truth.
staged_size: Option<u64>,
}
impl Fussy {
@@ -1000,6 +1276,10 @@ mod derived_guard_tests {
puts: puts.clone(),
last_put: last_put.clone(),
caps: dr_sync::Capabilities::minimal(),
advertise: None,
bodies: Default::default(),
moves: Default::default(),
staged_size: None,
},
puts,
last_put,
@@ -1017,10 +1297,36 @@ mod derived_guard_tests {
}
async fn list(
&self,
_dir: &RemotePath,
dir: &RemotePath,
_since: Option<&dr_sync::Validator>,
) -> Result<Vec<dr_sync::RemoteEntry>, RemoteError> {
Ok(Vec::new())
let entry = |name: &str, size: u64| {
let path = RemotePath::new(format!("{}/{name}", dir.as_str()));
dr_sync::RemoteEntry {
id: RemoteId::Path(path.clone()),
path,
kind: dr_sync::EntryKind::File,
validator: dr_sync::Validator::new("v"),
size,
modified: None,
has_preview: false,
materialised: true,
}
};
let mut out = Vec::new();
if let Some(size) = self.advertise {
out.push(entry(CATALOG_NAME, size));
}
// The staged upload lists at the size of what was last put, as a
// server that assembled it correctly would report — unless a test
// says the assembly went wrong.
if self.puts.load(Ordering::SeqCst) > 0 {
let held = self
.staged_size
.unwrap_or(self.last_put.lock().unwrap().len() as u64);
out.push(entry(UPLOAD_NAME, held));
}
Ok(out)
}
async fn dir_validator(
&self,
@@ -1036,7 +1342,7 @@ mod derived_guard_tests {
}
async fn get(
&self,
_id: &RemoteId,
id: &RemoteId,
_r: Option<std::ops::Range<u64>>,
) -> Result<Vec<u8>, RemoteError> {
match &self.fail_with {
@@ -1045,7 +1351,17 @@ mod derived_guard_tests {
Err(RemoteError::NotMaterialised(s.clone()))
}
Some(_) => Err(RemoteError::PermissionDenied),
None => Ok(self.body.clone()),
None if self.bodies.is_empty() => Ok(self.body.clone()),
None => {
let name = match id {
RemoteId::Path(p) => p.name().to_string(),
_ => String::new(),
};
self.bodies
.get(&name)
.cloned()
.ok_or(RemoteError::NotFound(name))
}
}
}
async fn put(
@@ -1065,7 +1381,15 @@ mod derived_guard_tests {
) -> Result<(), RemoteError> {
Ok(())
}
async fn move_to(&self, _f: &RemoteId, _t: &RemotePath) -> Result<(), RemoteError> {
async fn move_to(&self, f: &RemoteId, t: &RemotePath) -> Result<(), RemoteError> {
let from = match f {
RemoteId::Path(p) => p.name().to_string(),
_ => String::new(),
};
self.moves
.lock()
.unwrap()
.push((from, t.name().to_string()));
Ok(())
}
async fn create_dir(&self, _p: &RemotePath) -> Result<(), RemoteError> {
@@ -1135,6 +1459,204 @@ mod derived_guard_tests {
assert!(report.catalog_uploaded);
}
// --- a damaged copy on the server (FR-NC-9) --------------------------
//
// The one exception to everything above. A file SQLite calls malformed is
// not a read that failed; it is a file no device will ever read again, and
// leaving it alone pins it there for every client at once.
/// The bytes of a catalog holding one collection, as a peer would upload.
fn a_catalog_with(collection: &str, name: &str) -> Vec<u8> {
let dir = std::env::temp_dir().join(format!("dr-generation-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("catalog.sqlite");
let cat = dr_catalog::Catalog::open(&path).unwrap();
cat.connection()
.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES (?1, ?2, 0, 0, 1, 1)",
rusqlite::params![format!("uuid-{collection}"), collection],
)
.unwrap();
let snap = dir.join("snap.sqlite");
cat.snapshot_for_upload(&snap).unwrap();
let bytes = std::fs::read(&snap).unwrap();
let _ = std::fs::remove_dir_all(&dir);
bytes
}
/// A body that is definitely not a SQLite database as the current copy,
/// with a chosen advertised size and whatever generations the test wants.
async fn corrupt_remote(
body_len: usize,
advertised: Option<u64>,
generations: &[(usize, Vec<u8>)],
name: &str,
) -> (usize, SyncReport, Vec<(String, String)>) {
let (catalog_path, scratch) = fixture(name);
let (mut backend, puts, _) = Fussy::serving(None, Vec::new());
backend.advertise = advertised;
backend
.bodies
.insert(CATALOG_NAME.to_string(), vec![0xAB; body_len]);
for (n, bytes) in generations {
backend.bodies.insert(generation_name(*n), bytes.clone());
}
let moves = backend.moves.clone();
let mut report = SyncReport::default();
sync_catalog(
&backend,
&RemotePath::new(".darkroom-derived"),
&catalog_path,
&scratch,
&mut report,
)
.await
.unwrap();
let moves = moves.lock().unwrap().clone();
(puts.load(Ordering::SeqCst), report, moves)
}
#[tokio::test]
async fn a_damaged_catalog_that_arrived_whole_is_replaced() {
// The deadlock this exists to break: every device downloads the same
// unreadable file, every device declines to overwrite it, and
// collections and people stop crossing between devices for ever.
let (puts, report, moves) = corrupt_remote(64, Some(64), &[], "corrupt-whole").await;
assert_eq!(puts, 1, "ours goes up, to the staging name");
assert!(report.catalog_replaced, "and the report says what happened");
assert!(report.catalog_uploaded);
assert!(
!report.catalog_merged,
"there was nothing readable to merge"
);
// The damaged file is kept by the rotation, not thrown away.
assert!(moves.contains(&(CATALOG_NAME.into(), generation_name(1))));
assert_eq!(
moves.last().unwrap(),
&(UPLOAD_NAME.to_string(), CATALOG_NAME.to_string()),
"and the upload is moved into place last"
);
}
#[tokio::test]
async fn a_damaged_catalog_falls_back_to_the_generation_before_it() {
// What generations are for. The device that pushed the damaged copy
// may have been the only one holding some collection; the generation
// before it still has everything every device had agreed on.
let older = a_catalog_with("Iceland", "gen1");
let (puts, report, _) =
corrupt_remote(64, Some(64), &[(1, older)], "corrupt-with-gen").await;
assert!(report.catalog_merged, "merged from catalog.1.sqlite");
assert_eq!(report.collections_gained, 1, "and gained what it held");
assert!(report.catalog_replaced);
assert_eq!(puts, 1);
}
#[tokio::test]
async fn a_damaged_generation_is_skipped_for_the_one_behind_it() {
// Two bad pushes in a row must not be worse than one.
let older = a_catalog_with("Faroe", "gen2");
let (_, report, _) = corrupt_remote(
64,
Some(64),
&[(1, vec![0xCD; 64]), (2, older)],
"corrupt-two-deep",
)
.await;
assert!(report.catalog_merged);
assert_eq!(report.collections_gained, 1);
}
#[tokio::test]
async fn a_short_download_is_a_truncated_transfer_not_a_damaged_file() {
// The failure that matters most to get right. A phone on a flaky link
// aborts bodies constantly, and a partial download will not open
// either — treating that as damage would let one bad connection
// destroy a catalog the server was holding perfectly well.
let (puts, report, moves) = corrupt_remote(64, Some(4096), &[], "corrupt-short").await;
assert_eq!(puts, 0, "nothing is written over a copy that arrived short");
assert!(moves.is_empty(), "and nothing is rotated");
assert!(!report.catalog_replaced);
assert!(!report.catalog_uploaded);
}
#[tokio::test]
async fn a_size_the_server_will_not_confirm_leaves_the_copy_alone() {
// Not knowing is not the same as knowing it is whole. Without a size
// to compare against there is no way to tell damage from truncation,
// and the answer to "I cannot tell" has to stay "do not overwrite".
let (puts, report, _) = corrupt_remote(64, None, &[], "corrupt-unconfirmed").await;
assert_eq!(puts, 0);
assert!(!report.catalog_replaced);
}
#[tokio::test]
async fn a_push_rotates_oldest_first_and_lands_last() {
// The order is the safety: every destination is empty when it is
// moved into, so a failure at any step leaves a gap and never a
// missing current copy.
let (puts, report, moves) =
run_with_moves(Some(RemoteError::NotFound("nope".into())), "rotation").await;
assert_eq!(puts, 1);
assert!(report.catalog_uploaded);
assert_eq!(
moves,
vec![
(generation_name(2), generation_name(3)),
(generation_name(1), generation_name(2)),
(CATALOG_NAME.to_string(), generation_name(1)),
(UPLOAD_NAME.to_string(), CATALOG_NAME.to_string()),
]
);
}
#[tokio::test]
async fn an_upload_the_server_holds_at_the_wrong_size_is_not_rotated_in() {
// The assembly went wrong on the server. Rotating that into place would
// hand every other device the damaged file the generations exist to
// survive; discarding it costs one retry.
let (catalog_path, scratch) = fixture("wrong-size");
let (mut backend, puts) = Fussy::reading(Some(RemoteError::NotFound("nope".into())));
backend.staged_size = Some(7);
let moves = backend.moves.clone();
let mut report = SyncReport::default();
sync_catalog(
&backend,
&RemotePath::new(".darkroom-derived"),
&catalog_path,
&scratch,
&mut report,
)
.await
.unwrap();
assert_eq!(puts.load(Ordering::SeqCst), 1, "it was uploaded");
assert!(!report.catalog_uploaded, "but not accepted");
assert!(moves.lock().unwrap().is_empty(), "and nothing was rotated");
}
async fn run_with_moves(
fail_with: Option<RemoteError>,
name: &str,
) -> (usize, SyncReport, Vec<(String, String)>) {
let (catalog_path, scratch) = fixture(name);
let (backend, puts) = Fussy::reading(fail_with);
let moves = backend.moves.clone();
let mut report = SyncReport::default();
sync_catalog(
&backend,
&RemotePath::new(".darkroom-derived"),
&catalog_path,
&scratch,
&mut report,
)
.await
.unwrap();
let moves = moves.lock().unwrap().clone();
(puts.load(Ordering::SeqCst), report, moves)
}
// --- the place (FR-UI-8) ---------------------------------------------
//
// The same "do not write over what you could not read" rule as above, for a
+304 -2
View File
@@ -778,6 +778,27 @@ pub struct DevelopSession {
/// a history the *call sites* had to remember would be one press of undo
/// away from wrong every time a control is added.
history: History,
/// TRACES: FR-DEV-5
/// The named snapshots of this edit, as the sidecar had them plus what
/// this sitting took, minus what it deleted.
///
/// Beside the history rather than inside it, because they answer a
/// different question. The history is what was done in this sitting and
/// is deliberately forgotten with it; a snapshot is a state the
/// photographer *named*, which is the act of saying it should outlive
/// the sitting. It is persisted as a version of the sidecar pointing at
/// this one (`Version::snapshot_of`), which is what makes it survive a
/// restart and reach the other device.
snapshots: Vec<dr_pipeline::Version>,
/// The ids of snapshots deleted this sitting, so the save can remove
/// them from the file without removing what another device added since
/// — see `Sidecar::replace_snapshots`.
removed_snapshots: Vec<String>,
/// TRACES: FR-DEV-7
/// The snapshot the canvas is showing instead of the edit, while a
/// comparison is held. Viewing state: nothing about the edit changes,
/// and it goes down with the session.
compared_snapshot: Option<String>,
demosaiced: Arc<DemosaicedImage>,
adjust: AdjustPass,
/// TRACES: FR-DSP-7
@@ -1041,6 +1062,9 @@ impl DevelopSession {
ctx: ctx.clone(),
graph,
history,
snapshots: Vec::new(),
removed_snapshots: Vec::new(),
compared_snapshot: None,
demosaiced: Arc::new(demosaiced),
adjust: AdjustPass::new(ctx),
histogram: HistogramPass::new(ctx)
@@ -2938,7 +2962,7 @@ impl DevelopSession {
/// The join of the first is meaningless — there is nothing before it to
/// join to — and the panel shows it as the selection the layer *is*
/// rather than as a row with a chip that does nothing.
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize)> {
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize, bool)> {
let Some(layer) = self.graph.masks().get(id) else {
return Vec::new();
};
@@ -2950,11 +2974,30 @@ impl DevelopSession {
.iter()
.position(|&j| j == p.join)
.unwrap_or(0);
(p.id.clone(), p.source.kind().to_string(), join)
(p.id.clone(), p.source.kind().to_string(), join, p.hidden)
})
.collect()
}
/// TRACES: FR-DEV-19a
/// Leave one part out of the build, or put it back. An edit, and one
/// history step, for the same reason the layer's own switch is: the part
/// really is out until it is switched back.
pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) {
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
let Some(part) = layer.part_mut(index) else {
return;
};
if part.hidden == hidden {
return;
}
part.hidden = hidden;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED));
}
/// TRACES: FR-DEV-19a
/// Join a fresh painted part to a layer, returning its index.
///
@@ -5427,6 +5470,162 @@ impl DevelopSession {
self.history.reset(&self.graph);
}
// ---- named snapshots ---------------------------------------------------
/// TRACES: FR-DEV-5
/// Hand the session the snapshots its sidecar holds. Called once, on
/// open, beside [`Self::apply_version`].
pub fn set_snapshots(&mut self, snapshots: Vec<dr_pipeline::Version>) {
self.snapshots = snapshots;
self.removed_snapshots.clear();
}
/// The snapshots as they stand, oldest first.
pub fn snapshots(&self) -> &[dr_pipeline::Version] {
&self.snapshots
}
/// The ids deleted this sitting, for the save.
pub fn removed_snapshots(&self) -> &[String] {
&self.removed_snapshots
}
/// TRACES: FR-DEV-5
/// Name the state the photograph is in, and keep it. Returns the id.
///
/// Not a history step: taking a snapshot changes nothing about the edit,
/// and an undo that removed one would be undoing a decision to remember
/// rather than a change to the photograph. Deleting one is the same.
///
/// The id is stamped with the second and a per-process random word
/// rather than counted, because two devices can each take a snapshot of
/// the same photograph and both have to survive the merge — which keys
/// on this id, and would fold two `snap-3`s into one.
pub fn take_snapshot(&mut self, name: &str) -> String {
use std::hash::{BuildHasher, Hasher};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let salt = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
let id = format!("snap-{now}-{:08x}", salt as u32);
let name = name.trim();
let name = if name.is_empty() {
format!("Snapshot {}", self.snapshots.len() + 1)
} else {
name.to_string()
};
let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph);
// The stack with the model's coverage folded in, for the reason the
// save uses it: a subject layer stored by identity alone renders as
// nothing until a model is run, and a snapshot restored on the other
// device, or in a batch export, never gets one.
version.masks = self.masks_for_storage();
version.modified = now;
self.snapshots.push(version);
id
}
/// TRACES: FR-DEV-5
/// Put the photograph back the way a snapshot has it. One history step,
/// so it is undoable as a whole, exactly as a paste is.
pub fn restore_snapshot(&mut self, id: &str) -> bool {
let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else {
return false;
};
let rebake = version.apply(&mut self.graph);
self.pay_film_debt(&rebake);
self.history
.record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED));
true
}
/// TRACES: FR-DEV-5
pub fn rename_snapshot(&mut self, id: &str, name: &str) {
let name = name.trim();
if name.is_empty() {
return;
}
if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) {
v.name = name.to_string();
}
}
/// TRACES: FR-DEV-5
/// Forget a snapshot. Remembered as a deletion so the save takes it out
/// of the file rather than merely not putting it back.
pub fn delete_snapshot(&mut self, id: &str) {
let before = self.snapshots.len();
self.snapshots.retain(|v| v.uuid != id);
if self.snapshots.len() != before {
self.removed_snapshots.push(id.to_string());
}
if self.compared_snapshot.as_deref() == Some(id) {
self.compared_snapshot = None;
}
}
/// TRACES: FR-DEV-7
/// Hold a comparison against a snapshot, or let it go. Returns whether
/// anything changed, so a repeat costs no render.
pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool {
let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id));
if self.compared_snapshot.as_deref() == id {
return false;
}
self.compared_snapshot = id.map(str::to_string);
true
}
/// The snapshot being held against the edit, if one is.
pub fn compared_snapshot(&self) -> Option<&str> {
self.compared_snapshot.as_deref()
}
/// TRACES: FR-DEV-7
/// Render the photograph as a snapshot has it, without becoming it.
///
/// The same suspension [`Self::render_original`] uses — borrow the graph
/// for one render and hand it back — because it is the same question
/// about a different reference point: "the version I liked twenty
/// minutes ago" instead of the file. Nothing is recorded and nothing is
/// marked modified. A held comparison against a snapshot that has since
/// been deleted falls back to the edit itself, which is what is on
/// screen anyway.
pub fn render_compared(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
let Some(version) = self
.compared_snapshot
.as_deref()
.and_then(|id| self.snapshots.iter().find(|v| v.uuid == id))
.cloned()
else {
return self.render(width, height);
};
let saved = self.graph.state();
let debt = version.apply(&mut self.graph);
self.pay_film_debt(&debt);
let rendered = self.render(width, height);
let debt = self.graph.set_state(&saved);
self.pay_film_debt(&debt);
rendered
}
/// TRACES: FR-DEV-5
/// The snapshot list as the panel draws it, oldest first.
pub fn snapshot_rows(&self) -> Vec<crate::SnapshotRow> {
self.snapshots
.iter()
.map(|v| crate::SnapshotRow {
id: v.uuid.as_str().into(),
name: v.name.as_str().into(),
comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()),
})
.collect()
}
/// TRACES: FR-DEV-3f | FR-DEV-5
/// Pay what a restored edit owes the picture.
///
@@ -6519,6 +6718,109 @@ mod tests {
);
}
/// TRACES: FR-DEV-5
/// A snapshot is a state the photographer named: taking one changes
/// nothing, going back to it is one step, and undo takes the whole of
/// that step back.
#[test]
fn a_snapshot_is_restored_as_one_step_and_undone_as_one() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let rows = session.rows();
let row = rows
.iter()
.find(|row| {
session.set_param(row.op_index, row.param_index, row.maximum);
!session.is_neutral()
})
.expect("some control in the panel moves the picture")
.clone();
let liked = session.copy_settings();
let steps_before = session.history_rows().len();
let id = session.take_snapshot("Liked this");
assert_eq!(session.snapshots().len(), 1);
assert_eq!(session.snapshots()[0].name, "Liked this");
assert_eq!(
session.history_rows().len(),
steps_before,
"naming a state is not a change to the photograph"
);
// Move on, then go back.
session.set_param(row.op_index, row.param_index, row.minimum);
let moved_on = session.copy_settings();
assert_ne!(moved_on, liked, "the premise: the edit has moved");
let steps_moved = session.history_rows().len();
assert!(session.restore_snapshot(&id));
assert_eq!(session.copy_settings(), liked, "back to the named state");
assert_eq!(
session.history_rows().len(),
steps_moved + 1,
"restoring is one step"
);
assert!(session.undo());
assert_eq!(
session.copy_settings(),
moved_on,
"and undo takes the whole restore back"
);
// A name nobody typed is numbered rather than blank.
session.take_snapshot(" ");
assert_eq!(session.snapshots()[1].name, "Snapshot 2");
session.delete_snapshot(&id);
assert_eq!(session.snapshots().len(), 1);
assert_eq!(session.removed_snapshots(), [id.as_str()]);
}
/// TRACES: FR-DEV-7 | FR-DEV-5
/// Holding a snapshot against the edit is the same bargain as holding
/// the original: the picture changes, and nothing else does.
#[test]
fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let rows = session.rows();
let row = rows
.iter()
.find(|row| {
session.set_param(row.op_index, row.param_index, row.maximum);
!session.is_neutral()
})
.expect("some control in the panel moves the picture")
.clone();
let id = session.take_snapshot("Bright");
session.set_param(row.op_index, row.param_index, row.minimum);
let edit = session.copy_settings();
let steps = session.history_rows().len();
assert!(session.compare_snapshot(Some(&id)), "the hold began");
assert!(
!session.compare_snapshot(Some(&id)),
"a repeat of the same hold is not a change"
);
assert_eq!(session.compared_snapshot(), Some(id.as_str()));
session
.render_compared(64, 64)
.expect("render the snapshot");
assert_eq!(session.copy_settings(), edit, "every parameter comes back");
assert_eq!(session.history_rows().len(), steps, "looking is not a step");
assert!(session.compare_snapshot(None), "and letting go is one");
assert!(session.compared_snapshot().is_none());
assert!(
!session.compare_snapshot(Some("nothing-by-this-name")),
"a snapshot that does not exist cannot be held"
);
}
/// TRACES: FR-DEV-3 | FR-CAT-8
/// Reopening an edited photograph renders its subject mask, with no model.
///
+22 -1
View File
@@ -111,6 +111,20 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop",
touch: "Double-tap its track",
pointer: "Double-click its track, or right-click it",
keys: "R, for the control last moved",
},
Gesture {
title: "See the photograph as a snapshot had it",
section: "Develop",
touch: "Press and hold the eye beside the snapshot",
pointer: "Press and hold the eye beside the snapshot",
keys: "",
},
Gesture {
title: "Keep the photograph as it is now, under a name",
section: "Develop",
touch: "Type a name in the History panel and press Snapshot",
pointer: "Type a name in the History panel and press Snapshot",
keys: "",
},
Gesture {
@@ -118,7 +132,7 @@ pub const GESTURES: &[Gesture] = &[
section: "Develop",
touch: "Tap the ring at the head of its row",
pointer: "Click the ring at the head of its row",
keys: "",
keys: "H, for the selected layer history step, unlike holding \"Before\" — the layer really is off until it is switched back on.",
},
Gesture {
title: "Show or hide one mask on the photograph",
@@ -127,6 +141,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Click the eye on its row",
keys: "",
},
Gesture {
title: "Leave one part out of a mask, and put it back",
section: "Develop",
touch: "Tap the ring on the part's row",
pointer: "Click the ring on the part's row",
keys: "",
},
Gesture {
title: "Pick a collection up to rearrange the tree",
section: "Collections sidebar",
+8
View File
@@ -28,6 +28,9 @@ pub mod step {
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
pub const FILM: LocalizedKey = LocalizedKey("history.film");
/// TRACES: FR-DEV-5
/// The photograph put back to a named snapshot, as one step.
pub const SNAPSHOT_RESTORED: LocalizedKey = LocalizedKey("history.snapshot_restored");
/// TRACES: FR-DEV-3
/// A neutral was picked off the photograph, setting the white balance.
@@ -71,6 +74,7 @@ pub mod step {
/// A selection joined to a mask, taken out of it, or joined the other way.
pub const MASK_PART_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added");
pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed");
pub const MASK_PART_TOGGLED: LocalizedKey = LocalizedKey("history.mask_part_toggled");
pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined");
/// TRACES: FR-DEV-8
@@ -96,6 +100,7 @@ pub mod step {
dr_pipeline::history::OPENED,
dr_pipeline::history::UNNAMED,
PASTE,
SNAPSHOT_RESTORED,
FILM,
SAMPLED_NEUTRAL,
RESET_ALL,
@@ -121,6 +126,7 @@ pub mod step {
MASK_ERASED,
MASK_PART_ADDED,
MASK_PART_REMOVED,
MASK_PART_TOGGLED,
MASK_JOINED,
SPOT,
SPOT_PLACED,
@@ -287,6 +293,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
"history.opened" => "Opened",
"history.edit" => "Edit",
"history.paste" => "Paste Settings",
"history.snapshot_restored" => "Restore A Snapshot",
"history.film" => "Film Stock",
"history.sampled_neutral" => "Sample Neutral",
@@ -315,6 +322,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
"history.mask_erased" => "Erase Mask",
"history.mask_part_added" => "Add To Mask",
"history.mask_part_removed" => "Remove From Mask",
"history.mask_part_toggled" => "Show Or Hide Part Of A Mask",
"history.mask_joined" => "Change How A Mask Joins",
"history.spot" => "Adjust Repair",
"history.spot_placed" => "Add Repair",
+18 -1
View File
@@ -816,9 +816,26 @@ fn open_in_browser(url: &str) -> std::io::Result<()> {
{
android_open_url(url).map_err(|e| std::io::Error::other(e))
}
// TRACES: FR-PLAT-WIN-2
// `ShellExecute` by way of the shell's URL handler, which is what a
// double-click on a link does, and needs no crate: `rundll32
// url.dll,FileProtocolHandler` has opened the default browser since
// Windows 98 and is still what the platform documents for the purpose.
// Not `cmd /C start`, whose quoting of `&` in a query string is a
// well-known trap.
#[cfg(windows)]
{
std::process::Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", url])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(|_| ())
}
#[cfg(not(any(
all(unix, not(target_os = "android"), not(target_os = "macos")),
target_os = "android"
target_os = "android",
windows
)))]
{
let _ = url;
+176
View File
@@ -59,6 +59,7 @@ mod settings_ui;
mod sidecar_cache;
mod spots_ui;
mod trash;
mod xmp_sync;
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
@@ -414,6 +415,12 @@ fn reset_view_state(window: &AppWindow) {
// the first click on the *next* photograph sample it, which is a click
// nobody meant to spend.
window.global::<Adjustments>().set_sampling_op(-1);
// TRACES: FR-DEV-16
// And the control last moved, which R resets: the indices are into this
// photograph's rows, and a reset that reached back into the previous edit
// would land on whichever control happens to share them.
window.global::<Adjustments>().set_touched_op(-1);
window.global::<Adjustments>().set_touched_param(-1);
// TRACES: FR-DSP-7
// Emptied rather than left standing: the previous photograph's histogram
// beside the next one's filename is a confident, precise lie, and the gap
@@ -1186,6 +1193,79 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
None => window.set_backend("NO GPU".into()),
}
// TRACES: NFR-OPS-1
// The diagnostics bundle, wired as the two presses the requirement
// describes. Preparing gathers the log and the crash records into memory
// and shows what would be written; saving writes it; discarding drops it
// and writes nothing. The gathered bundle is held between the presses so
// that what is saved is exactly what was shown — a bundle gathered again
// at save time could differ from its own preview by whatever was logged
// while the user was reading.
{
use dr_plat::diagnostics::bundle::{Bundle, Facts};
let facts = Facts {
app_version: env!("CARGO_PKG_VERSION").to_string(),
schema_version: dr_catalog::schema::SCHEMA_VERSION,
graphics: match &gpu {
Some(ctx) => format!(
"{} ({:?}), {}",
ctx.adapter_name(),
ctx.backend(),
ctx.driver()
),
None => "no GPU".to_string(),
},
};
let pending: Rc<RefCell<Option<Bundle>>> = Rc::new(RefCell::new(None));
{
let weak = window.as_weak();
let pending = pending.clone();
window.on_diagnostics_prepare(move || {
let Some(w) = weak.upgrade() else { return };
let bundle = Bundle::gather(&facts);
w.set_diagnostics_preview(
bundle
.preview(&dr_plat::diagnostics::bundle::default_dir())
.into(),
);
w.set_diagnostics_result("".into());
*pending.borrow_mut() = Some(bundle);
});
}
{
let weak = window.as_weak();
let pending = pending.clone();
window.on_diagnostics_save(move || {
let Some(w) = weak.upgrade() else { return };
let Some(bundle) = pending.borrow_mut().take() else {
return;
};
let dir = dr_plat::diagnostics::bundle::default_dir();
let result = match bundle.write_to(&dir) {
Ok(path) => {
log::info!("diagnostics bundle written: {}", path.display());
format!("Saved as {}", path.display())
}
Err(e) => {
log::warn!("diagnostics bundle not written: {e}");
format!("Could not save the bundle to {}: {e}", dir.display())
}
};
w.set_diagnostics_preview("".into());
w.set_diagnostics_result(result.into());
});
}
{
let weak = window.as_weak();
window.on_diagnostics_discard(move || {
let Some(w) = weak.upgrade() else { return };
*pending.borrow_mut() = None;
w.set_diagnostics_preview("".into());
w.set_diagnostics_result("".into());
});
}
}
// Every background job reports here, and this draws the bar across the top
// of the shell and fills the settings page's list. Built before the
// controllers because they take a handle to it: a job that starts during
@@ -1504,6 +1584,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let stored = settings.snapshot();
library.set_cache_budget(stored.cache.original_budget_bytes);
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
library.set_write_xmp_sidecars(stored.library.write_xmp_sidecars);
library.set_timeline_bars(stored.library.timeline_bars);
library.set_face_model_id(stored.faces.detector.model_id());
}
@@ -1635,6 +1716,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// cache would sit over budget indefinitely.
lib.set_cache_budget(s.cache.original_budget_bytes);
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars);
if let Some(w) = weak.upgrade() {
refresh_export_label(&w, &ctl);
}
@@ -1759,6 +1841,11 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// the list is rebuilt when it would read differently, not when the picture
// is redrawn, and those are very different rates.
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
// TRACES: FR-DEV-5
// And the snapshot list, compared by value rather than by a revision: it
// is a handful of rows, and a held comparison changes one of them
// without a step being taken.
let drawn_snapshots: Rc<RefCell<Option<Vec<SnapshotRow>>>> = Rc::new(RefCell::new(None));
// TRACES: FR-CULL-3
// How the photographer wants focus peaking drawn, or `None` for off.
@@ -1795,6 +1882,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let session = session.clone();
let viewport = viewport.clone();
let drawn_history = drawn_history.clone();
let drawn_snapshots = drawn_snapshots.clone();
let display = display.clone();
let chosen_peaking = chosen_peaking.clone();
Rc::new(move |window: &AppWindow, draft: bool| {
@@ -1837,6 +1925,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
steps.set_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
steps.set_undo_label(s.undo_label().into());
}
// TRACES: FR-DEV-5
// The snapshots, on the same path. Rebuilt only when the list
// would read differently, for the reason the steps are: a held
// comparison redraws, and it is the one thing here that changes
// a row without changing the history.
let snapshots = s.snapshot_rows();
if drawn_snapshots.borrow().as_ref() != Some(&snapshots) {
*drawn_snapshots.borrow_mut() = Some(snapshots.clone());
steps.set_snapshots(slint::ModelRc::new(slint::VecModel::from(snapshots)));
}
// TRACES: FR-DEV-3
// Which part of the region overlay the view is showing. Here
@@ -1900,6 +1998,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
s.render_uncropped(w, h).map(|(image, _, _)| image)
} else if window.get_showing_original() {
s.render_original(w, h)
} else if s.compared_snapshot().is_some() {
// TRACES: FR-DEV-7
// The same hold, against a snapshot rather than the file.
s.render_compared(w, h)
} else {
s.render(w, h)
};
@@ -2964,6 +3066,80 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}
});
}
// TRACES: FR-DEV-5
// Named snapshots. Taking and deleting one change the list and nothing
// else — no rows, no history — so they redraw only to push the list,
// through the same path everything else pushes it on. Restoring one is
// the paste's shape: the whole edit moves, so the rows resync and the
// picture is redrawn.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Steps>().on_snapshot_taken(move |name| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.take_snapshot(&name);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.global::<Steps>().on_snapshot_restored(move |id| {
let Some(w) = weak.upgrade() else { return };
let restored = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.restore_snapshot(&id));
if restored {
sync_rows(&w, &rows, &session);
// The mask panel too: a snapshot carries its layers, and
// the redraw syncs the repairs and the overlay but not the
// rows the layers are listed in.
masks_ui::sync(&w, &session);
redraw(&w);
}
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Steps>().on_snapshot_removed(move |id| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.delete_snapshot(&id);
}
redraw(&w);
});
}
{
// TRACES: FR-DEV-7
// Holding a snapshot against the edit, and letting it go — the
// Before button's shape, and its rule: no rows are synced and no
// history is touched. A full frame rather than a draft, for the
// reason the original is: a soft comparison shows a difference the
// edit does not have.
let weak = window.as_weak();
let session = session.clone();
let render_now = render_now.clone();
window
.global::<Steps>()
.on_snapshot_compared(move |id, down| {
let Some(w) = weak.upgrade() else { return };
let changed = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.compare_snapshot(down.then_some(id.as_str())));
if changed {
render_now(&w, false);
}
});
}
// ---- zoom, pan and crop ---------------------------------------------
//
+268 -33
View File
@@ -544,6 +544,11 @@ pub enum Amendment {
/// `Some(None)` is a real edit: "develop this normally again". Without
/// the distinction, clearing a film could never be saved.
film: Option<Option<dr_pipeline::sidecar::FilmRef>>,
/// TRACES: FR-DEV-5
/// The named snapshots, when this is an image's own edit being
/// written back: the ones the session holds, and the ids it deleted.
/// A paste carries none — a snapshot is a state of one photograph.
snapshots: Option<(Vec<dr_pipeline::Version>, Vec<String>)>,
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The local adjustments, when this is an image's own edit being
/// written back rather than a paste onto someone else's.
@@ -795,6 +800,7 @@ fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
scope,
masks,
film,
..
} => {
preset.amend(&mut version.params, *scope);
// TRACES: FR-DEV-3f
@@ -823,6 +829,17 @@ fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
version.modified = now_secs();
sidecar.put(version);
// TRACES: FR-DEV-5 | FR-NC-9
// After the version, and against the uuid the fuse settled on: a
// snapshot points at its edit by uuid, and the edit may have just been
// renamed onto the canonical one.
if let Amendment::Settings {
snapshots: Some((kept, removed)),
..
} = &w.amendment
{
sidecar.replace_snapshots(&w.version_uuid, kept.clone(), removed);
}
sidecar
}
@@ -1138,18 +1155,16 @@ pub fn place_path(account: &Account) -> PathBuf {
/// reached the server, is the worst failure this application can have, and
/// it would be silent.
///
/// `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
/// catalog.
/// `dr_sync::account::declared_data_dir` is the persistent per-app directory
/// the Android entry point establishes before anything opens a store. A
/// desktop declares nothing and takes the platform's data directory from
/// `dr_plat::dirs` — XDG on Linux, `%LOCALAPPDATA%` on Windows — which keeps
/// the established location on Linux rather than moving anyone's catalog.
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::AccountStore::data_dir);
base.join("darkroom")
match dr_sync::account::declared_data_dir() {
Some(declared) => declared.join("darkroom"),
None => dr_plat::base_dir(dr_plat::Base::Data),
}
}
/// TRACES: FR-NC-10 | NFR-R1
@@ -1481,6 +1496,34 @@ async fn pull_sidecars(
};
let text = String::from_utf8_lossy(&bytes);
// TRACES: FR-CAT-13
// A standard XMP beside the photograph — Lightroom's, darktable's,
// anybody's — takes the other branch: reconciled field by field with
// the catalog winning, and a disagreement recorded for the reload
// the requirement asks to be offered. See `xmp_sync`.
if crate::xmp_sync::is_xmp(path) {
match crate::xmp_sync::take_in(conn, root_id, path, &text, now_secs()) {
Ok(taken) => {
applied += taken.changed;
if !taken.conflicts.is_empty() {
log::info!(
"xmp sidecar {path} disagrees with the catalog on {:?}; \
a reload is offered in Settings",
taken.conflicts
);
}
// Recorded only once it reached a photograph: a sidecar
// that arrived before its image is read again next time.
if taken.described > 0 {
record_sidecar_read(conn, root_id, path, &entry.validator);
}
}
Err(e) => log::warn!("xmp sidecar at {path} is unreadable ({e})"),
}
continue;
}
let mut sidecar = match dr_pipeline::Sidecar::parse(&text) {
Ok(s) => s,
// Unreadable is not empty. Recording the ETag would mean never
@@ -1517,6 +1560,191 @@ async fn pull_sidecars(
Ok(applied)
}
/// TRACES: FR-CAT-13 | NFR-R4
/// One image's ratings, label and keywords, on their way to the `.xmp`
/// beside it.
///
/// Distinct from [`SidecarWrite`], which amends DarkRoom's own document
/// through the cache and the outbox. This is best-effort in the other
/// direction: the catalog and the `.drsc` are authoritative, the `.xmp` is a
/// courtesy to whatever else reads the folder, and a write that cannot
/// happen now is written again — from the catalog, whole — the next time
/// anything about the photograph is judged. So nothing is queued.
#[derive(Debug, Clone)]
pub struct XmpWrite {
pub image_path: String,
pub record: dr_xmp::Xmp,
}
/// TRACES: FR-CAT-13 | NFR-R4
/// Write each record into the XMP sidecar beside its image.
///
/// An existing sidecar of either spelling is rewritten in place, which is the
/// whole point of `dr_xmp::rewrite`: only the properties DarkRoom owns move,
/// and another application's settings, comments and namespaces come through
/// byte for byte. A photograph with neither gets a new file under Lightroom's
/// name. Reported once at the end, as the judgement writes are.
pub fn spawn_xmp_writes(conn: Connection, writes: Vec<XmpWrite>) -> Receiver<XmpMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut written = 0usize;
let mut failed = 0usize;
let mut last_error = None;
let rt = match crate::net_runtime::build() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(XmpMessage::Finished {
written: 0,
failed: writes.len(),
last_error: Some(e.to_string()),
});
return;
}
};
rt.block_on(async {
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
failed = writes.len();
last_error = Some(e.to_string());
return;
}
};
for w in &writes {
match write_one_xmp(&*backend, w).await {
Ok(()) => written += 1,
Err(e) => {
log::warn!("xmp sidecar for {}: {e}", w.image_path);
last_error = Some(e);
failed += 1;
}
}
}
});
let _ = tx.send(XmpMessage::Finished {
written,
failed,
last_error,
});
});
rx
}
/// The outcome of a batch of XMP writes.
#[derive(Debug)]
pub enum XmpMessage {
Finished {
written: usize,
failed: usize,
last_error: Option<String>,
},
}
/// Read-modify-write one image's XMP sidecar on the server.
async fn write_one_xmp(backend: &dyn RemoteBackend, w: &XmpWrite) -> Result<(), String> {
let [darktable, lightroom] = crate::xmp_sync::candidate_paths(&w.image_path);
// Whichever exists is the one rewritten; neither existing means the
// Lightroom spelling is created. An existing file this build cannot
// parse is left alone rather than replaced — it is somebody else's
// document, and a refusal is recoverable where an overwrite is not.
let mut target = lightroom.clone();
let mut existing: Option<String> = None;
for path in [&darktable, &lightroom] {
if let Ok(bytes) = backend
.get(&RemoteId::Path(RemotePath::new(path.clone())), None)
.await
{
if !bytes.is_empty() {
target = path.clone();
existing = Some(String::from_utf8_lossy(&bytes).into_owned());
break;
}
}
}
let text = match existing {
Some(text) => {
// The file's caption, copyright and hierarchy come through: the
// catalog has nowhere to keep them, and a rewrite that said
// nothing about them would remove them.
let theirs = dr_xmp::Xmp::parse(&text)
.map_err(|e| format!("{target} is not a sidecar this build can read: {e}"))?;
let mut record = w.record.clone();
crate::xmp_sync::carry_through(&mut record, &theirs);
record
.rewrite(&text)
.map_err(|e| format!("{target} is not a sidecar this build can rewrite: {e}"))?
}
None => w.record.to_text(),
};
backend
.put(&RemotePath::new(target), text.into_bytes(), None)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
/// TRACES: FR-CAT-13
/// The offered reload: re-read the sidecars a person chose to trust, with
/// the sidecar winning. One fetch per path, the catalog opened on this
/// thread as the scan opens it.
pub fn spawn_xmp_reload(
conn: Connection,
root: String,
catalog_path: PathBuf,
paths: Vec<String>,
) -> Receiver<XmpMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut written = 0usize;
let mut failed = 0usize;
let mut last_error = None;
let outcome: Result<(), String> = (|| {
let catalog = Catalog::open(&catalog_path).map_err(|e| e.to_string())?;
let root_id: i64 = catalog
.connection()
.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[&root],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
let rt = crate::net_runtime::build().map_err(|e| e.to_string())?;
rt.block_on(async {
let backend = crate::remote::connect(&conn).map_err(|e| e.to_string())?;
for path in &paths {
let fetched = backend
.get(&RemoteId::Path(RemotePath::new(path.clone())), None)
.await
.map_err(|e| e.to_string())
.and_then(|bytes| {
let text = String::from_utf8_lossy(&bytes);
crate::xmp_sync::reload(catalog.connection(), root_id, path, &text)
});
match fetched {
Ok(taken) => written += taken.changed,
Err(e) => {
log::warn!("reloading {path}: {e}");
last_error = Some(e);
failed += 1;
}
}
}
Ok(())
})
})();
if let Err(e) = outcome {
failed = paths.len();
last_error = Some(e);
}
let _ = tx.send(XmpMessage::Finished {
written,
failed,
last_error,
});
});
rx
}
/// What this device has already read, so a pull fetches only what changed.
fn load_sidecar_etags(
catalog: &Catalog,
@@ -4423,14 +4651,30 @@ pub fn face_models(
account: &Account,
detector: dr_types::FaceDetector,
) -> Option<(PathBuf, PathBuf)> {
let pair = |dir: PathBuf| {
let pair = |dir: &PathBuf| {
let detector = dir.join(detector.file_name());
let embedder = dir.join("arcface_mbf_b1.onnx");
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
};
pair(face_models_dir(account))
.or_else(|| pair(shared_face_models_dir()))
.or_else(|| system_face_models_dirs().into_iter().find_map(pair))
let mut searched = vec![face_models_dir(account), shared_face_models_dir()];
searched.extend(system_face_models_dirs());
let found = searched.iter().find_map(pair);
if found.is_none() {
// The settings page can only say "not installed". This is the line
// that says where it looked, which is the whole of what a user with
// the files in the wrong place needs — and the first thing to read
// when a freshly installed package reports no model.
log::warn!(
"face models: no directory holds both {} and arcface_mbf_b1.onnx; searched {}",
detector.file_name(),
searched
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
found
}
/// The scene model, its vocabulary and its category descriptor, if all three
@@ -4466,25 +4710,16 @@ pub fn scene_model(account: &Account) -> Option<(PathBuf, PathBuf, PathBuf)> {
/// Where a *package* may have installed the models.
///
/// `$XDG_DATA_DIRS` rather than a hard-coded `/usr/share`, because that is the
/// variable a distribution, a prefix install, or a Nix-style store sets to say
/// where its data went, and the default it falls back to is exactly the pair of
/// paths that would otherwise have been hard-coded.
///
/// Empty on Android, which has no such directories: there the APK's copy is
/// unpacked into the shared user directory instead, because an asset inside a
/// package is not a path anything can read from (ARCH §6.9).
/// `dr_plat::system_data_dirs` has the rule per platform: `$XDG_DATA_DIRS` on
/// Linux, the executable's own directory on Windows, nothing on Android —
/// there the APK's copy is unpacked into the shared user directory instead,
/// because an asset inside a package is not a path anything can read from
/// (ARCH §6.9). Last in the search order on every platform, so a pair the
/// user placed by hand outranks the installed one.
fn system_face_models_dirs() -> Vec<PathBuf> {
if cfg!(target_os = "android") {
return Vec::new();
}
let dirs = std::env::var("XDG_DATA_DIRS")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.split(':')
.filter(|d| !d.is_empty())
.map(|d| PathBuf::from(d).join("darkroom").join("models"))
dr_plat::system_data_dirs()
.into_iter()
.map(|d| d.join("models"))
.collect()
}
+262 -4
View File
@@ -298,6 +298,9 @@ pub struct LibraryController {
/// Drains the sidecar writer. Held so a second judgement replaces the
/// timer rather than leaving two draining the same finished channel.
sidecar_timer: RefCell<Option<slint::Timer>>,
/// TRACES: FR-CAT-13
/// The same, for a batch of XMP writes or a reload.
xmp_timer: RefCell<Option<slint::Timer>>,
/// Which window load the model belongs to, bumped by [`load_window`].
///
/// A thumbnail worker addresses cells by *row index into the window that
@@ -381,6 +384,10 @@ pub struct LibraryController {
/// small budget still keeps a working set. A metered or small-disk device
/// wants the first.
keep_opened: std::cell::Cell<bool>,
/// TRACES: FR-CAT-13 | NFR-R4
/// Whether judgements and keywords also go to the `.xmp` beside the
/// original. Mirrors `LibrarySettings::write_xmp_sidecars`.
write_xmp: std::cell::Cell<bool>,
/// TRACES: FR-CAT-6
/// How many bars the capture-time axis is cut into, from the settings
/// page.
@@ -450,6 +457,7 @@ impl LibraryController {
current_bucket: RefCell::new(None),
filter: RefCell::new(library::RatingFilter::default()),
sidecar_timer: RefCell::new(None),
xmp_timer: RefCell::new(None),
generation: std::cell::Cell::new(0),
reachability: RefCell::new(dr_sync::Reachability::new()),
root_lost: RefCell::new(None),
@@ -466,6 +474,9 @@ impl LibraryController {
keep_opened: std::cell::Cell::new(
dr_types::CacheSettings::default().keep_opened_originals,
),
write_xmp: std::cell::Cell::new(
dr_types::LibrarySettings::default().write_xmp_sidecars,
),
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
face_model_id: RefCell::new(dr_types::FaceDetector::default().model_id().to_string()),
})
@@ -505,6 +516,11 @@ impl LibraryController {
self.keep_opened.set(keep);
}
/// TRACES: FR-CAT-13 | NFR-R4
pub fn set_write_xmp_sidecars(&self, on: bool) {
self.write_xmp.set(on);
}
/// TRACES: FR-NC-6a
/// Set the ceiling on passively cached originals, and apply it now.
///
@@ -1266,6 +1282,11 @@ fn drain_scan(
log::info!("library folder is readable again");
}
refresh_offline(&w, ctl);
// TRACES: FR-CAT-13
// The pull may have found an `.xmp` that disagrees
// with the catalog; the settings page offers the
// reload, and this is what tells it how many.
refresh_xmp_conflicts(&w, ctl);
// An incremental rescan lists almost nothing, so
// reporting the listed count would read as "0 images"
@@ -3086,6 +3107,7 @@ fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, as
window.set_library_error(slint::SharedString::new());
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
refresh_keywords(window, ctl, &images);
start_xmp_writes(window, ctl, &images);
// A filtered grid may no longer hold what was just keyworded — taking
// "puffin" off an image while showing only puffins means it belongs
@@ -3194,6 +3216,7 @@ fn apply_judgement(
}
start_sidecar_writes(window, ctl, writes);
start_xmp_writes(window, ctl, images);
}
/// What the status line says about a judgement that just landed.
@@ -3321,6 +3344,9 @@ fn collect_settings_writes(
// not a parameter. Pasting one would be pasting a choice the
// clipboard never captured.
film: None,
// Nor snapshots: they are states of the photograph they were
// taken on, and mean nothing on another.
snapshots: None,
},
})
});
@@ -3382,6 +3408,186 @@ fn collect_sidecar_writes(
}
/// Push judgements out to sidecars on a worker, reporting once at the end.
/// TRACES: FR-CAT-13 | NFR-R4
/// Write these images' ratings, labels and keywords to the `.xmp` beside
/// each, where the user has switched that on.
///
/// The record is read from the catalog *now*, whole, rather than carried
/// from the gesture: a rating and a keyword typed a second apart are two
/// writes of the same file, and the second must not carry a copy of the
/// first taken before it landed.
pub(crate) fn start_xmp_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
) {
if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() {
return;
}
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let writes: Vec<library::XmpWrite> = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let c = catalog.connection();
images
.iter()
.filter_map(|&image| {
let version = dr_catalog::rating::default_version_id(c, image).ok()?;
let image_path: String = c
.query_row(
"SELECT source_ref FROM images WHERE id = ?1",
[image.0 as i64],
|r| r.get(0),
)
.ok()?;
Some(library::XmpWrite {
image_path,
record: crate::xmp_sync::record_of(c, image, version),
})
})
.collect()
};
if writes.is_empty() {
return;
}
let count = writes.len();
let rx = library::spawn_xmp_writes(conn, writes);
let job = ctl.activity.begin(
crate::activity::Kind::Upload,
format!("Writing {count} XMP sidecar(s)"),
);
drain_xmp(window.as_weak(), ctl.clone(), rx, job, false);
}
/// TRACES: FR-CAT-13
/// The offered reload: take the sidecars' values for every photograph the
/// last pull found disagreeing with the catalog.
pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let paths: Vec<String> = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(root_id) = root_id_of(catalog, &conn.account.root) else {
return;
};
crate::xmp_sync::conflicts(catalog, root_id)
.into_iter()
.map(|c| c.path)
.collect()
};
if paths.is_empty() {
return;
}
let count = paths.len();
let rx = library::spawn_xmp_reload(
conn.clone(),
conn.account.root.clone(),
library::catalog_path(&conn.account),
paths,
);
let job = ctl.activity.begin(
crate::activity::Kind::Download,
format!("Reloading {count} XMP sidecar(s)"),
);
drain_xmp(window.as_weak(), ctl.clone(), rx, job, true);
}
/// Wait for a batch of XMP work to report, then say what it did.
fn drain_xmp(
weak: slint::Weak<AppWindow>,
ctl: Rc<LibraryController>,
rx: std::sync::mpsc::Receiver<library::XmpMessage>,
job: crate::activity::Activity,
reload: bool,
) {
let timer = slint::Timer::default();
let job = RefCell::new(Some(job));
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
move || {
let ctl = &ctl_cb;
let message = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stop(&ctl.xmp_timer);
return;
}
};
stop(&ctl.xmp_timer);
let library::XmpMessage::Finished {
written,
failed,
last_error,
} = message;
let status = match (reload, failed, last_error) {
(false, 0, _) => format!("{written} XMP sidecar(s) written"),
(true, 0, _) => format!("{written} photograph(s) reloaded from XMP"),
(_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"),
(_, n, None) => format!("{n} XMP sidecar(s) failed"),
};
if let Some(job) = job.borrow_mut().take() {
if failed > 0 {
job.fail(status.clone());
} else {
job.finish(status.clone());
}
}
if let Some(w) = weak.upgrade() {
w.set_library_status(status.into());
if reload {
// The grid draws what the reload changed, and the
// settings page stops offering what is settled.
let visible = ctl.visible_ids();
if let Some(catalog) = ctl.catalog.borrow().as_ref() {
sync_ratings(&w, catalog, &visible);
refresh_rating_counts(&w, catalog);
}
refresh_xmp_conflicts(&w, ctl);
}
}
},
);
*ctl.xmp_timer.borrow_mut() = Some(timer);
}
/// TRACES: FR-CAT-13
/// How many sidecars the last pull found disagreeing with the catalog, for
/// the settings page to offer the reload against.
pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc<LibraryController>) {
let count = (|| {
let (conn, _) = ctl.session.borrow().clone()?;
let borrow = ctl.catalog.borrow();
let catalog = borrow.as_ref()?;
let root_id = root_id_of(catalog, &conn.account.root)?;
Some(crate::xmp_sync::conflicts(catalog, root_id).len())
})()
.unwrap_or(0);
window.set_settings_xmp_conflicts(count as i32);
}
fn root_id_of(catalog: &Catalog, root: &str) -> Option<i64> {
catalog
.connection()
.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[root],
|r| r.get(0),
)
.ok()
}
pub(crate) fn start_sidecar_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
@@ -3949,6 +4155,34 @@ fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
refresh_timeline(window, catalog, ctl);
}
/// TRACES: NFR-R2
/// Take the day's catalog backup if one is due, off the UI thread.
///
/// Decided cheaply first — [`dr_catalog::recovery::backup_due`] reads a
/// directory listing, not the catalog — so the ordinary case of "backed up
/// this morning already" costs no thread and no connection.
fn spawn_scheduled_backup(ctl: &Rc<LibraryController>) {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let catalog_path = library::catalog_path(&conn.account);
if !dr_catalog::recovery::backup_due(&catalog_path) {
return;
}
std::thread::spawn(move || {
let catalog = match dr_catalog::Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
log::warn!("scheduled backup: opening the catalog: {e}");
return;
}
};
if let Err(e) = dr_catalog::recovery::backup_if_due(catalog.connection(), &catalog_path) {
log::warn!("scheduled backup: {e}");
}
});
}
/// Push shards and the catalog to the server, and take what it has.
///
/// Fired after the sweep completes, when there is a finished index worth
@@ -4080,10 +4314,14 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
report.shards_uploaded,
report.shards_downloaded,
report.thumbnails_adopted,
if report.catalog_uploaded {
"pushed"
} else {
"not pushed"
// Replacing a damaged copy is said apart from an
// ordinary push: it is the one push that discarded
// something, and a log line that called it "pushed"
// would hide the only moment worth going back to.
match (report.catalog_uploaded, report.catalog_replaced) {
(true, true) => "pushed over a damaged copy",
(true, false) => "pushed",
_ => "not pushed",
},
if report.collections_gained > 0 {
format!(", {} collection(s) gained", report.collections_gained)
@@ -4235,6 +4473,14 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
refresh_timeline(&w, catalog, &ctl_cb);
}
}
// TRACES: NFR-R2
// The scheduled backup, at the moment the catalog is
// quietest and a day's edits have just been folded in.
// On its own thread, with its own connection: a copy
// of a 130 MB file is a second or two the Slint loop
// must not spend, and it runs beside the sync below,
// which is only a reader of the same file.
spawn_scheduled_backup(&ctl_cb);
// Now that indexing is complete, hand the result to the
// server so a second device inherits it rather than
// repeating hours of range fetches.
@@ -5500,6 +5746,18 @@ pub fn wire<F>(
// controllers would hold each other alive for the life of the process.
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
// TRACES: FR-CAT-13
// The reload the settings page offers when a sidecar disagrees.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_settings_xmp_reload(move || {
if let Some(w) = weak.upgrade() {
start_xmp_reload(&w, &ctl);
}
});
}
// TRACES: FR-UI-3 | FR-UI-4
// Whether the rating strip waits to be hovered or stands open.
//
+49 -1
View File
@@ -275,12 +275,13 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
.mask_parts(id)
.into_iter()
.enumerate()
.map(|(i, (part_id, label, join))| PartRow {
.map(|(i, (part_id, label, join, hidden))| PartRow {
id: part_id.into(),
label: label.into(),
join: join as i32,
selected: i == s.active_part(),
base: i == 0,
hidden,
})
.collect(),
_ => Vec::new(),
@@ -665,6 +666,34 @@ pub(crate) fn wire(
redraw(&w);
});
}
{
// TRACES: FR-DEV-16
// The keyboard's copy of the ring. A mixed selection goes to shown:
// a layer nobody can see is the one being asked about, and turning
// the rest off to match it would hide work in the name of hiding it.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Masking>().on_selected_toggled(move || {
let Some(w) = weak.upgrade() else { return };
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
let ids: Vec<String> = s.active_masks().to_vec();
if ids.is_empty() {
return;
}
let all_shown = ids
.iter()
.all(|id| s.masks().get(id).is_some_and(|l| l.enabled));
for id in &ids {
s.set_mask_enabled(id, !all_shown);
}
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
@@ -1056,6 +1085,25 @@ pub(crate) fn wire(
redraw(&w);
});
}
{
// TRACES: FR-DEV-19a
// A part left out of the build. The picture changes and the row's
// eye does, so both are redrawn; the adjust rows are not, because
// the layer's chain is not what moved.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Masking>()
.on_part_hidden_toggled(move |id, index, hidden| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_part_hidden(&id, index.max(0) as usize, hidden);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
+96 -4
View File
@@ -315,6 +315,7 @@ pub fn save_local(
preset: &Preset,
scope: Scope,
masks: Option<&dr_pipeline::mask::MaskStack>,
snapshots: Option<(&[dr_pipeline::Version], &[String])>,
) -> Result<(), String> {
let path = local_sidecar_path(image);
@@ -369,6 +370,10 @@ pub fn save_local(
version.revision = version.revision.saturating_add(1);
version.modified = now_secs();
sidecar.put(version);
// TRACES: FR-DEV-5
if let Some((kept, removed)) = snapshots {
sidecar.replace_snapshots(&uuid, kept.to_vec(), removed);
}
let text = sidecar.to_text();
@@ -401,9 +406,9 @@ pub fn save_open_edit(
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
// Both taken in one borrow: they are one edit, and a mask stack captured
// All taken in one borrow: they are one edit, and a mask stack captured
// from a session that had already moved on would be a different image's.
let Some((preset, masks, film)) = session.borrow().as_ref().map(|s| {
let Some((preset, masks, film, snapshots, removed)) = session.borrow().as_ref().map(|s| {
(
s.copy_settings(),
// TRACES: FR-DEV-3
@@ -429,6 +434,12 @@ pub fn save_open_edit(
.unwrap_or_default()
}),
}),
// TRACES: FR-DEV-5
// The snapshots as they stand and the ids deleted this sitting,
// so the file gains what was taken and loses what was removed —
// and keeps what another device added meanwhile.
s.snapshots().to_vec(),
s.removed_snapshots().to_vec(),
)
}) else {
return;
@@ -443,7 +454,13 @@ pub fn save_open_edit(
// `Everything`: this is the image's *own* edit being written back,
// not a paste onto someone else's. Excluding framing here would
// make a crop the one adjustment that never survived a restart.
if let Err(e) = save_local(path, &preset, Scope::everything(), Some(&masks)) {
if let Err(e) = save_local(
path,
&preset,
Scope::everything(),
Some(&masks),
Some((&snapshots, &removed)),
) {
log::warn!("saving {}: {e}", path.display());
}
}
@@ -461,6 +478,7 @@ pub fn save_open_edit(
// sliders were written and every mask was dropped.
masks: Some(masks),
film: Some(film),
snapshots: Some((snapshots, removed)),
},
};
library_ui::start_sidecar_writes(window, library, vec![write]);
@@ -536,6 +554,15 @@ pub fn apply_stored_edit(
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return false };
s.apply_version(version);
// TRACES: FR-DEV-5
// And the snapshots taken of it, on this device or another.
s.set_snapshots(
sidecar
.snapshots_of(&version.uuid)
.into_iter()
.cloned()
.collect(),
);
}
crate::sync_rows(window, rows, session);
@@ -1162,6 +1189,7 @@ mod tests {
&Preset::capture(&graph),
Scope::everything(),
Some(graph.masks()),
None,
)
.unwrap();
@@ -1209,6 +1237,7 @@ mod tests {
&Preset::capture(&edited()),
Scope::everything(),
None,
None,
)
.unwrap();
@@ -1242,6 +1271,7 @@ mod tests {
&Preset::capture(&edited()),
Scope::everything(),
None,
None,
)
.unwrap();
save_local(
@@ -1249,6 +1279,7 @@ mod tests {
&Preset::capture(&edited()),
Scope::everything(),
None,
None,
)
.unwrap();
@@ -1267,6 +1298,7 @@ mod tests {
&Preset::capture(&edited()),
Scope::everything(),
None,
None,
)
.unwrap();
let first = load_local(&image)
@@ -1279,6 +1311,7 @@ mod tests {
&Preset::capture(&edited()),
Scope::everything(),
None,
None,
)
.unwrap();
let second = load_local(&image)
@@ -1313,6 +1346,7 @@ mod tests {
&Preset::capture(&edited()),
Scope::everything(),
None,
None,
)
.unwrap();
@@ -1335,7 +1369,8 @@ mod tests {
&image,
&Preset::capture(&edited()),
Scope::everything(),
None
None,
None,
)
.is_err());
assert_eq!(
@@ -1353,6 +1388,7 @@ mod tests {
&Preset::capture(&edited()),
Scope::everything(),
None,
None,
)
.unwrap();
@@ -1638,4 +1674,60 @@ mod tests {
"the failed overwrite kept the new value"
);
}
/// TRACES: FR-DEV-5
/// A snapshot goes into the file beside the edit and comes back out as a
/// snapshot of it; deleting one takes it out of the file; and neither
/// touches the edit the photograph opens at.
#[test]
fn snapshots_survive_the_local_sidecar_and_leave_it_when_deleted() {
let dir = tempdir("snapshots");
let image = dir.join("IMG_0001.CR3");
std::fs::write(&image, b"").unwrap();
let graph = edited();
let mut liked = EditGraph::default_chain();
liked.set_param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE,
1.5,
);
let snapshot = dr_pipeline::Version::from_graph("snap-1", "Brighter", &liked);
save_local(
&image,
&Preset::capture(&graph),
Scope::everything(),
None,
Some((&[snapshot], &[])),
)
.unwrap();
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
let sidecar = Sidecar::parse(&text).unwrap();
let edit = sidecar.default_version().expect("the edit");
assert!(!edit.is_snapshot(), "the photograph opens at its edit");
let back = sidecar.snapshots_of(&edit.uuid);
assert_eq!(back.len(), 1);
assert_eq!(back[0].name, "Brighter");
assert_eq!(
back[0]
.params
.get(&("exposure".to_string(), "exposure".to_string())),
Some(&1.5)
);
save_local(
&image,
&Preset::capture(&graph),
Scope::everything(),
None,
Some((&[], &["snap-1".to_string()])),
)
.unwrap();
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
let sidecar = Sidecar::parse(&text).unwrap();
let edit = sidecar.default_version().expect("the edit is still there");
assert!(sidecar.snapshots_of(&edit.uuid).is_empty(), "{text}");
}
}
+10 -10
View File
@@ -25,17 +25,17 @@ pub struct SettingsStore {
impl SettingsStore {
/// Open the store at the platform config location.
///
/// Linux: `$XDG_CONFIG_HOME/darkroom/settings.json`, falling back to
/// `~/.config` (FR-PLAT-LIN-1) — the same resolution `SessionStore` does,
/// so the two files sit together and a user backing up one takes both.
/// **The same resolution `SessionStore` makes**, by calling the same
/// function, so `settings.json` sits beside `sessions.json` on every
/// platform. That is `dr_plat::dirs`' config directory on a desktop and
/// the directory the entry point declared on Android — the second half
/// was the bug: this used to read `XDG_CONFIG_HOME` and `HOME` itself,
/// neither exists on Android, and the result was `.config/darkroom`
/// relative to a working directory of `/`. Every settings edit on a
/// tablet failed with "read-only file system", and the page said so
/// without saying why.
pub fn open() -> Self {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
})
.join("darkroom");
Self::open_at(dir.join("settings.json"))
Self::open_at(dr_sync::account::config_dir().join("settings.json"))
}
/// Open at an explicit path — for tests, and for a non-default location.
+11
View File
@@ -154,6 +154,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
window.set_settings_thumbnail_budget(budget::label(s.cache.thumbnail_budget_bytes).into());
window.set_settings_thumbnail_unlimited(s.cache.thumbnail_budget_bytes.is_none());
window.set_settings_keep_opened(s.cache.keep_opened_originals);
window.set_settings_write_xmp(s.library.write_xmp_sidecars);
// --- develop -------------------------------------------------------
//
@@ -501,6 +502,16 @@ pub fn wire<F, G>(
render(&w, &ctl);
});
}
{
// TRACES: FR-CAT-13 | NFR-R4
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_write_xmp_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.library.write_xmp_sidecars = on);
render(&w, &ctl);
});
}
// --- faces ---------------------------------------------------------
//
+597
View File
@@ -0,0 +1,597 @@
//! TRACES: FR-CAT-13 | FR-NC-9 | NFR-R4
//! Standard XMP sidecars, read into the catalog and written back out of it.
//!
//! `dr-xmp` reads and writes the file. This is the other half the requirement
//! asks for and the half `docs/outstanding.md` called "the larger": which
//! images a given `.xmp` describes, what the catalog holds about them, how
//! the two are reconciled, where a disagreement goes, and the write in the
//! other direction. Nothing here parses XML and nothing in `dr-xmp` knows a
//! catalog exists.
//!
//! # Two conventions for one file
//!
//! Lightroom writes `IMG_0001.xmp` beside `IMG_0001.CR3`; darktable writes
//! `IMG_0001.CR3.xmp`. Both are answered by [`images_for`]: the path with
//! `.xmp` taken off is tried as an image path first, exactly, and failing that
//! as a stem shared with the images beside it — the rule DarkRoom's own
//! sidecar already follows, under which a RAW and the JPEG the camera wrote
//! beside it are one photograph (FR-CAT-11) and share the document.
//!
//! # Precedence, and where a disagreement goes
//!
//! A standard XMP carries no revision and no device, so nothing in it can say
//! whether its rating is newer than the catalog's. The automatic pull
//! therefore runs [`dr_xmp::reconcile`] with the catalog winning: keywords
//! union, and every whole-valued field is taken only where the catalog holds
//! none. A genuine disagreement — both sides hold a value, and different ones —
//! is written to `xmp_conflicts` rather than resolved, and the requirement's
//! "a metadata reload offered" is that table with a button in front of it.
//! [`reload`] is the button: the same reconciliation with the sidecar winning,
//! asked for by a person.
//!
//! # Detection
//!
//! "External modification of an XMP sidecar shall be detected." The scan
//! already records the ETag of every sidecar it has taken in, and fetches
//! only those whose ETag has moved. An `.xmp` edited in another application
//! is precisely a file whose ETag has moved, so the detection is the pull's
//! ordinary incrementality — nothing watches a directory, and nothing needs
//! to. What is new is what happens after: the re-read reconciles again, and
//! the second reading is where a conflict first appears.
//!
//! # Writing, and why it is off
//!
//! NFR-R4 makes source-adjacent writes opt-in. A library shared with another
//! editor is one where a file DarkRoom wrote can be read by something else,
//! and that is exactly what [`Xmp::rewrite`] is built for — it rewrites only
//! the properties DarkRoom owns and copies everything else through byte for
//! byte. But the option to write beside somebody's originals is theirs to
//! switch on, and until they do the catalog and DarkRoom's own sidecar are the
//! only things a judgement reaches.
use dr_catalog::Catalog;
use dr_types::{FlagState, ImageId};
use dr_xmp::{Precedence, Rating, Xmp};
use rusqlite::Connection;
/// The two places an image's XMP sidecar may be, in the order they are
/// tried when writing: the darktable spelling first, because it names the
/// image unambiguously, then Lightroom's.
///
/// When reading, whichever the scan found is the one read; this is for the
/// write, which has to choose. An existing file of either spelling is
/// rewritten in place, and a photograph with neither gets Lightroom's, since
/// it is the spelling more applications look for.
pub fn candidate_paths(image_path: &str) -> [String; 2] {
let stem = match image_path.rsplit_once('.') {
Some((stem, ext)) if !ext.contains('/') => stem,
_ => image_path,
};
[format!("{image_path}.xmp"), format!("{stem}.xmp")]
}
/// Whether a listing entry is a standard XMP sidecar rather than DarkRoom's.
pub fn is_xmp(path: &str) -> bool {
path.rsplit_once('.')
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case(dr_sync::scan::XMP_EXTENSION))
}
/// The images an `.xmp` at `path` describes: their ids and default versions.
///
/// Empty when the sidecar sits beside nothing this catalog knows, which is
/// the ordinary case for a file that arrived before its photograph was
/// scanned — the next pull reads it again, because no ETag is recorded for a
/// sidecar that reached nothing.
pub fn images_for(conn: &Connection, root_id: i64, path: &str) -> Vec<(ImageId, i64)> {
let Some(named) = path
.strip_suffix(".xmp")
.or_else(|| path.strip_suffix(".XMP"))
else {
return Vec::new();
};
let ids = |sql: &str, arg: &str| -> Vec<ImageId> {
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
stmt.query_map(rusqlite::params![root_id, arg], |r| r.get::<_, i64>(0))
.map(|rows| rows.flatten().map(|i| ImageId(i as u64)).collect())
.unwrap_or_default()
};
// darktable's spelling: the name before `.xmp` is the image itself.
let mut images = ids(
"SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2",
named,
);
if images.is_empty() {
// Lightroom's: a stem shared with the photograph, and with the JPEG
// beside it. The escape is what makes `%` and `_` in a folder name
// literal, and the Rust-side check is what actually decides — a LIKE
// is a filter, not an answer.
let prefix = named
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let candidates: Vec<(ImageId, String)> = {
let Ok(mut stmt) = conn.prepare(
"SELECT id, source_ref FROM images
WHERE root_id = ?1 AND source_ref LIKE ?2 ESCAPE '\\'",
) else {
return Vec::new();
};
stmt.query_map(rusqlite::params![root_id, format!("{prefix}.%")], |r| {
Ok((ImageId(r.get::<_, i64>(0)? as u64), r.get::<_, String>(1)?))
})
.map(|rows| rows.flatten().collect())
.unwrap_or_default()
};
images = candidates
.into_iter()
.filter(|(_, source)| candidate_paths(source)[1].eq_ignore_ascii_case(path))
.map(|(id, _)| id)
.collect();
}
images
.into_iter()
.filter_map(|image| {
let version = dr_catalog::rating::default_version_id(conn, image).ok()?;
Some((image, version))
})
.collect()
}
/// What the catalog holds about one image, as the sidecar would carry it.
///
/// Rating and flag are two axes here and one field there: a rejection is
/// written as Adobe's `-1` and a rating as its stars, and a frame that is
/// both rejected and starred loses the stars in the file — the loss the
/// format has and `dr_xmp::Rating` records. Reading goes the other way in
/// [`apply`].
pub fn record_of(conn: &Connection, image: ImageId, version: i64) -> Xmp {
let mut xmp = Xmp::default();
let row = conn
.query_row(
"SELECT rating, flag, label FROM versions WHERE id = ?1",
[version],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, Option<i64>>(2)?,
))
},
)
.ok();
if let Some((rating, flag, label)) = row {
xmp.rating = if flag == flag_code(FlagState::Reject) {
Some(Rating::Rejected)
} else if rating > 0 {
Some(Rating::Stars(rating.clamp(0, 5) as u8))
} else {
None
};
xmp.set_colour(dr_catalog::rating::label_from_code(label));
}
xmp.keywords = dr_catalog::keywords::for_image(conn, image).unwrap_or_default();
xmp
}
/// Write a reconciled record onto one image.
///
/// Only what the record holds: a `None` rating is *unrated* and is not
/// written over a star, for the reason `dr_pipeline::sidecar::merge_judgement`
/// gives — a file that says nothing cannot erase an afternoon's culling. A
/// rejection sets the flag and leaves the stars alone; stars set the rating
/// and, if the frame was rejected, lift the rejection, since the file said
/// it was worth a number. Keywords are added and never removed here, which
/// is what a union means. `label` is set where the file names one of the
/// five colours.
///
/// Returns whether any column moved.
pub fn apply(
conn: &Connection,
image: ImageId,
version: i64,
record: &Xmp,
) -> Result<bool, String> {
let mut changed = false;
match record.rating {
Some(Rating::Rejected) => {
changed |= conn
.execute(
"UPDATE versions SET flag = ?2 WHERE id = ?1 AND flag <> ?2",
rusqlite::params![version, flag_code(FlagState::Reject)],
)
.map_err(|e| e.to_string())?
> 0;
}
Some(Rating::Stars(n)) if n > 0 => {
changed |= conn
.execute(
"UPDATE versions
SET rating = ?2,
flag = CASE WHEN flag = ?3 THEN 0 ELSE flag END
WHERE id = ?1 AND (rating <> ?2 OR flag = ?3)",
rusqlite::params![version, n.min(5) as i64, flag_code(FlagState::Reject)],
)
.map_err(|e| e.to_string())?
> 0;
}
_ => {}
}
if let Some(colour) = record.colour() {
changed |= conn
.execute(
"UPDATE versions SET label = ?2 WHERE id = ?1 AND label IS NOT ?2",
rusqlite::params![version, dr_catalog::rating::label_code(colour)],
)
.map_err(|e| e.to_string())?
> 0;
}
if !record.keywords.is_empty() {
let have = dr_catalog::keywords::for_image(conn, image).unwrap_or_default();
for word in &record.keywords {
if have.iter().any(|h| h.eq_ignore_ascii_case(word)) {
continue;
}
match dr_catalog::keywords::assign(conn, &[image], word) {
Ok(n) => changed |= n > 0,
// A word the vocabulary refuses — empty once trimmed, or a
// separator on its own — costs that word and not the file.
Err(e) => log::debug!("xmp keyword {word:?} on {}: {e}", image.0),
}
}
}
Ok(changed)
}
/// TRACES: FR-CAT-13
/// Before rewriting an existing sidecar, keep what the catalog cannot hold.
///
/// `Xmp::rewrite` replaces the owned properties wholesale — that is what
/// makes "no rating" mean the rating goes — and the catalog has columns for
/// three of them: rating and flag, label, keywords. A record built from the
/// catalog therefore says nothing about a title, a caption, a copyright
/// line or a hierarchical subject, and writing it as it stands would delete
/// all four from a sidecar Lightroom wrote, on every judgement. So those
/// come through from the file, and so does a label that is not one of the
/// five colours — the catalog kept no text for it, and the file's is the
/// only copy.
pub fn carry_through(record: &mut Xmp, existing: &Xmp) {
record.hierarchical_subjects = existing.hierarchical_subjects.clone();
record.title = existing.title.clone();
record.description = existing.description.clone();
record.creators = existing.creators.clone();
record.copyright = existing.copyright.clone();
record.credit = existing.credit.clone();
record.usage_terms = existing.usage_terms.clone();
if record.label.is_none() && existing.colour().is_none() {
record.label = existing.label.clone();
}
}
/// What one sidecar did when taken in.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TakenIn {
/// Images whose rows moved.
pub changed: usize,
/// Images the sidecar described at all. Zero means it sits beside nothing
/// this catalog has yet, and its ETag must not be recorded.
pub described: usize,
/// The fields both sides held and disagreed on, across every image.
pub conflicts: Vec<dr_xmp::Field>,
}
/// TRACES: FR-CAT-13 | FR-NC-9
/// Take a standard sidecar into the catalog, with the catalog winning.
///
/// The automatic path. Every image the file describes is reconciled against
/// what the catalog holds for it; the merged record is applied; and a
/// disagreement is recorded in `xmp_conflicts` for a person to settle, never
/// resolved here.
pub fn take_in(
conn: &Connection,
root_id: i64,
path: &str,
text: &str,
now: i64,
) -> Result<TakenIn, String> {
let sidecar = Xmp::parse(text).map_err(|e| e.to_string())?;
let mut out = TakenIn::default();
for (image, version) in images_for(conn, root_id, path) {
out.described += 1;
let mine = record_of(conn, image, version);
let reconciled = dr_xmp::reconcile(&mine, &sidecar, Precedence::Catalog);
if apply(conn, image, version, &reconciled.merged)? {
out.changed += 1;
}
for field in reconciled.conflicts {
if !out.conflicts.contains(&field) {
out.conflicts.push(field);
}
}
}
if out.conflicts.is_empty() {
clear_conflict(conn, root_id, path);
} else if out.described > 0 {
record_conflict(conn, root_id, path, &out.conflicts, now);
}
Ok(out)
}
/// TRACES: FR-CAT-13
/// The offered reload: the same reconciliation with the sidecar winning.
///
/// Asked for by a person, per file, which is the consent the module
/// documentation says this needs. Clears the conflict whatever the outcome —
/// the person has now seen it, and if the file still disagrees the next pull
/// that sees it change will say so again.
pub fn reload(conn: &Connection, root_id: i64, path: &str, text: &str) -> Result<TakenIn, String> {
let sidecar = Xmp::parse(text).map_err(|e| e.to_string())?;
let mut out = TakenIn::default();
for (image, version) in images_for(conn, root_id, path) {
out.described += 1;
let mine = record_of(conn, image, version);
let reconciled = dr_xmp::reconcile(&mine, &sidecar, Precedence::Sidecar);
// The sidecar wins outright on the whole-valued fields, and that
// includes a rating it holds and the catalog's it replaces — which
// `apply` does. What `apply` will not do is *clear* a star the file
// does not mention, and a reload does not ask it to: the file said
// nothing, and nothing is not a judgement.
if apply(conn, image, version, &reconciled.merged)? {
out.changed += 1;
}
}
clear_conflict(conn, root_id, path);
Ok(out)
}
/// One outstanding disagreement, for the settings page.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Conflict {
pub path: String,
pub fields: String,
}
/// Every sidecar in this library that disagrees with the catalog.
pub fn conflicts(catalog: &Catalog, root_id: i64) -> Vec<Conflict> {
let Ok(mut stmt) = catalog
.connection()
.prepare("SELECT path, fields FROM xmp_conflicts WHERE root_id = ?1 ORDER BY path")
else {
return Vec::new();
};
stmt.query_map([root_id], |r| {
Ok(Conflict {
path: r.get(0)?,
fields: r.get(1)?,
})
})
.map(|rows| rows.flatten().collect())
.unwrap_or_default()
}
fn record_conflict(
conn: &Connection,
root_id: i64,
path: &str,
fields: &[dr_xmp::Field],
now: i64,
) {
let fields = fields
.iter()
.map(|f| format!("{f:?}"))
.collect::<Vec<_>>()
.join(" ");
let done = conn.execute(
"INSERT INTO xmp_conflicts(root_id, path, fields, seen_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(root_id, path) DO UPDATE SET
fields = excluded.fields, seen_at = excluded.seen_at",
rusqlite::params![root_id, path, fields, now],
);
if let Err(e) = done {
log::debug!("recording xmp conflict for {path}: {e}");
}
}
fn clear_conflict(conn: &Connection, root_id: i64, path: &str) {
let _ = conn.execute(
"DELETE FROM xmp_conflicts WHERE root_id = ?1 AND path = ?2",
rusqlite::params![root_id, path],
);
}
fn flag_code(flag: FlagState) -> i64 {
match flag {
FlagState::Unflagged => 0,
FlagState::Pick => 1,
FlagState::Reject => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog() -> Catalog {
let c = Catalog::in_memory().unwrap();
c.connection()
.execute_batch(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib');
INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1, 1, 'Photos/IMG_0001.CR3', 0),
(2, 1, 'Photos/IMG_0001.JPG', 0),
(3, 1, 'Photos/IMG_0002.CR3', 0);",
)
.unwrap();
dr_catalog::rating::ensure_default_versions(c.connection()).unwrap();
c
}
const LIGHTROOM: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmp:Rating="4"
xmp:Label="Red">
<dc:subject><rdf:Bag><rdf:li>puffin</rdf:li><rdf:li>iceland</rdf:li></rdf:Bag></dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
/// Both spellings reach the photograph, and Lightroom's reaches the JPEG
/// beside it too.
#[test]
fn both_namings_find_their_images() {
let c = catalog();
let conn = c.connection();
let mut lr: Vec<u64> = images_for(conn, 1, "Photos/IMG_0001.xmp")
.into_iter()
.map(|(i, _)| i.0)
.collect();
lr.sort();
assert_eq!(lr, [1, 2], "the RAW and the JPEG are one photograph");
let dt: Vec<u64> = images_for(conn, 1, "Photos/IMG_0001.CR3.xmp")
.into_iter()
.map(|(i, _)| i.0)
.collect();
assert_eq!(dt, [1], "darktable's names the file exactly");
assert!(images_for(conn, 1, "Photos/IMG_9999.xmp").is_empty());
}
/// The ordinary pull: an empty catalog takes everything the file says.
#[test]
fn a_sidecar_fills_what_the_catalog_lacks() {
let c = catalog();
let conn = c.connection();
let taken = take_in(conn, 1, "Photos/IMG_0002.xmp", LIGHTROOM, 1).unwrap();
assert_eq!(taken.described, 1);
assert_eq!(taken.changed, 1);
assert!(taken.conflicts.is_empty());
let record = record_of(conn, ImageId(3), 3);
assert_eq!(record.rating, Some(Rating::Stars(4)));
assert_eq!(record.colour(), Some(dr_types::ColourLabel::Red));
assert_eq!(record.keywords, ["iceland", "puffin"]);
assert!(conflicts(&c, 1).is_empty());
}
/// A rating the catalog already holds is not overwritten by the file,
/// the disagreement is recorded, and the reload — a person asking —
/// takes the file's.
#[test]
fn a_disagreement_is_recorded_and_a_reload_settles_it() {
let c = catalog();
let conn = c.connection();
dr_catalog::rating::set_rating(conn, ImageId(3), 2).unwrap();
let taken = take_in(conn, 1, "Photos/IMG_0002.xmp", LIGHTROOM, 1).unwrap();
assert_eq!(taken.conflicts, [dr_xmp::Field::Rating]);
assert_eq!(
record_of(conn, ImageId(3), 3).rating,
Some(Rating::Stars(2)),
"the catalog's own rating stands"
);
assert_eq!(
record_of(conn, ImageId(3), 3).keywords,
["iceland", "puffin"],
"while the keywords still union"
);
let open = conflicts(&c, 1);
assert_eq!(open.len(), 1);
assert_eq!(open[0].path, "Photos/IMG_0002.xmp");
assert_eq!(open[0].fields, "Rating");
reload(conn, 1, "Photos/IMG_0002.xmp", LIGHTROOM).unwrap();
assert_eq!(
record_of(conn, ImageId(3), 3).rating,
Some(Rating::Stars(4))
);
assert!(conflicts(&c, 1).is_empty(), "settled");
}
/// A rejection and a star count are two axes here and one field there.
#[test]
fn a_rejection_crosses_as_adobes_minus_one_and_back() {
let c = catalog();
let conn = c.connection();
dr_catalog::rating::set_flag(conn, ImageId(3), FlagState::Reject).unwrap();
assert_eq!(
record_of(conn, ImageId(3), 3).rating,
Some(Rating::Rejected)
);
let back = Xmp {
rating: Some(Rating::Stars(3)),
..Default::default()
};
apply(conn, ImageId(3), 3, &back).unwrap();
let after = record_of(conn, ImageId(3), 3);
assert_eq!(
after.rating,
Some(Rating::Stars(3)),
"the stars lift the rejection"
);
}
/// A judgement written over a Lightroom sidecar must not cost the caption
/// Lightroom wrote, since the catalog never held it.
#[test]
fn a_rewrite_keeps_what_the_catalog_has_no_column_for() {
let theirs = Xmp {
title: Some("Puffins at dusk".into()),
copyright: Some("© Someone".into()),
hierarchical_subjects: vec!["Places|Iceland".into()],
label: Some("Second choice".into()),
rating: Some(Rating::Stars(1)),
..Default::default()
};
let mut ours = Xmp {
rating: Some(Rating::Stars(4)),
keywords: vec!["puffin".into()],
..Default::default()
};
carry_through(&mut ours, &theirs);
assert_eq!(ours.rating, Some(Rating::Stars(4)), "the judgement is ours");
assert_eq!(ours.title.as_deref(), Some("Puffins at dusk"));
assert_eq!(ours.copyright.as_deref(), Some("© Someone"));
assert_eq!(ours.hierarchical_subjects, ["Places|Iceland"]);
assert_eq!(
ours.label.as_deref(),
Some("Second choice"),
"a label that is not one of the five colours has no other copy"
);
let mut red = Xmp::default();
red.set_colour(Some(dr_types::ColourLabel::Red));
carry_through(&mut red, &theirs);
assert_eq!(
red.label.as_deref(),
Some("Red"),
"but a colour of ours wins"
);
}
/// The two spellings a write chooses between.
#[test]
fn the_write_tries_darktables_spelling_then_lightrooms() {
assert_eq!(
candidate_paths("Photos/IMG_0001.CR3"),
["Photos/IMG_0001.CR3.xmp", "Photos/IMG_0001.xmp"]
);
assert_eq!(
candidate_paths("a.b/IMG"),
["a.b/IMG.xmp", "a.b/IMG.xmp"],
"a dot in a folder is not an extension"
);
}
}
+14
View File
@@ -995,6 +995,18 @@ export global Adjustments {
callback param-changed(int, int, float);
callback param-reset(int, int);
/// TRACES: FR-DEV-16
/// The control last moved, so a key can put it back.
///
/// The generated rows have no focus: they are a model the repeater
/// rebuilds, and a focus ring for them would be a larger change than the
/// binding wants. What a photographer means by "reset that" from the
/// keyboard is the slider they just dragged too far, and that is a thing
/// the panel can remember without any of them being focused. -1 is none,
/// which is where every photograph starts — see `lib.rs`, which clears
/// these on open so the key cannot reach back into the previous edit.
in-out property <int> touched-op: -1;
in-out property <int> touched-param: -1;
/// Every parameter of one operation back to its default. A section's reset
/// and a curve's reset are the same action, so they share the one callback
/// rather than duplicating a handler that would have to be kept in step
@@ -1511,6 +1523,8 @@ export component AdjustPanel inherits Rectangle {
curve-channel: Adjustments.curve-channel;
drag-changed(on) => { root.slider-dragging = on; }
param-changed(op, param, v) => {
Adjustments.touched-op = op;
Adjustments.touched-param = param;
Adjustments.param-changed(op, param, v);
}
param-reset(op, param) => { Adjustments.param-reset(op, param); }
+46
View File
@@ -69,6 +69,14 @@ export component AppWindow inherits Window {
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
/// the binary it is part of.
in property <string> app-version: "unknown";
/// TRACES: NFR-OPS-1
/// The diagnostics bundle's preview and the last save's outcome — see
/// `SettingsPage`, which is the only reader.
in property <string> diagnostics-preview;
in property <string> diagnostics-result;
callback diagnostics-prepare();
callback diagnostics-save();
callback diagnostics-discard();
// Current image, for the status strip and empty state.
in property <string> filename: "";
@@ -838,6 +846,11 @@ export component AppWindow inherits Window {
in property <[string]> settings-timeline-bar-labels;
in property <int> settings-timeline-bars-selected: 0;
callback settings-timeline-bars-picked(int);
/// TRACES: FR-CAT-13
in property <bool> settings-write-xmp: false;
callback settings-write-xmp-toggled(bool);
in property <int> settings-xmp-conflicts: 0;
callback settings-xmp-reload();
/// TRACES: FR-UI-1
/// The group-navigation preference, and what "Automatic" resolves to here.
@@ -1273,6 +1286,10 @@ in property <bool> panel-visible: true;
timeline-bar-labels: root.settings-timeline-bar-labels;
timeline-bars-selected: root.settings-timeline-bars-selected;
timeline-bars-picked(i) => { root.settings-timeline-bars-picked(i); }
write-xmp: root.settings-write-xmp;
write-xmp-toggled(on) => { root.settings-write-xmp-toggled(on); }
xmp-conflicts: root.settings-xmp-conflicts;
xmp-reload() => { root.settings-xmp-reload(); }
cache-usage: root.settings-cache-usage;
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
@@ -1363,6 +1380,13 @@ in property <bool> panel-visible: true;
thumbnail-library() => { root.library-thumbnail-all(); }
index-faces() => { root.identity-index(); }
// TRACES: NFR-OPS-1
diagnostics-preview: root.diagnostics-preview;
diagnostics-result: root.diagnostics-result;
diagnostics-prepare() => { root.diagnostics-prepare(); }
diagnostics-save() => { root.diagnostics-save(); }
diagnostics-discard() => { root.diagnostics-discard(); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
}
@@ -2497,6 +2521,28 @@ in property <bool> panel-visible: true;
root.inspect-toggled(-1, -1);
return accept;
}
// TRACES: FR-DEV-16
// R puts back the control last moved. Bare, like
// Z: the modifier chords above have all returned.
// Nothing has been moved on this photograph until
// something has, so the key is silent until then
// rather than resetting a control nobody named.
if ((event.text == "r" || event.text == "R")
&& Adjustments.touched-op >= 0) {
Adjustments.param-reset(
Adjustments.touched-op,
Adjustments.touched-param);
return accept;
}
// TRACES: FR-DEV-16
// H hides or shows the selected mask layer — the
// ring at the head of its row, from the keyboard.
// With nothing selected there is nothing to hide,
// and Rust says so by doing nothing.
if (event.text == "h" || event.text == "H") {
Masking.selected-toggled();
return accept;
}
// GESTURE: Move to the next or previous photograph
// where: Develop
// touch: Tap a frame in the roll along the
+1
View File
@@ -295,6 +295,7 @@ export component SliderTrack inherits Rectangle {
// where: Develop
// touch: Double-tap its track
// pointer: Double-click its track, or right-click it
// keys: R, for the control last moved
// why: The column is 280px wide and the colour mixer alone
// puts thirty-six of these in it, so a reset button per
// row would be most of the width. Two ways in with a
+152 -1
View File
@@ -19,7 +19,7 @@
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { PanelHeading, Caption, Label, Value, Button } from "widgets.slint";
import { PanelHeading, Caption, Label, Value, Button, Field } from "widgets.slint";
// One step, flattened for Slint's model system.
export struct HistoryRow {
@@ -40,6 +40,105 @@ export struct HistoryRow {
undone: bool,
}
/// TRACES: FR-DEV-5
/// One named snapshot of the open edit.
export struct SnapshotRow {
/// Routing back to the core. Opaque to this file.
id: string,
name: string,
/// The canvas is showing this one instead of the edit, while held.
comparing: bool,
}
/// TRACES: FR-DEV-5 | FR-DEV-7
/// A snapshot: press it to go back to it, hold the eye beside it to look.
component SnapshotItem inherits Rectangle {
in property <SnapshotRow> data;
in property <bool> enabled: true;
callback restored();
/// Both edges, like "Before": down shows the snapshot, up shows the edit.
callback compared(bool);
callback removed();
height: Theme.row-height;
background: root.data.comparing
? Theme.selected
: (touch.has-hover ? Theme.hover : transparent);
// Behind the two buttons, so a press on either does its own job and a
// press anywhere else on the row is the restore.
touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.restored(); }
}
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Label {
text: root.data.name;
emphasised: root.data.comparing || touch.has-hover;
horizontal-stretch: 1;
overflow: elide;
vertical-alignment: center;
}
// GESTURE: See the photograph as a snapshot had it
// where: Develop
// touch: Press and hold the eye beside the snapshot
// pointer: Press and hold the eye beside the snapshot
// why: The same hold as "Before", against a point the
// photographer chose rather than the file: "the version
// I liked twenty minutes ago" is how a choice between two
// treatments is actually made. It takes no history step
// and changes nothing; letting go puts the edit back.
//
// TRACES: FR-DEV-7
eye := TouchArea {
width: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
pointer-event(event) => {
if event.kind == PointerEventKind.down
&& event.button == PointerEventButton.left {
root.compared(true);
}
if event.kind == PointerEventKind.up
|| event.kind == PointerEventKind.cancel {
root.compared(false);
}
}
Label {
text: "◐";
emphasised: eye.has-hover || root.data.comparing;
horizontal-alignment: center;
vertical-alignment: center;
}
}
cut := TouchArea {
width: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.removed(); }
Label {
text: "×";
emphasised: cut.has-hover;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
component StepRow inherits Rectangle {
in property <HistoryRow> data;
in property <bool> enabled: true;
@@ -115,6 +214,18 @@ export global Steps {
callback redo();
/// A row's own `index`, not its position in `rows`.
callback picked(int);
/// TRACES: FR-DEV-5
/// The named snapshots of the open edit, oldest first. Rust owns the
/// list; the panel only names, restores, deletes and holds them.
in property <[SnapshotRow]> snapshots;
/// The name typed for the next one. Empty is allowed: Rust numbers it.
callback snapshot-taken(string);
callback snapshot-restored(string);
callback snapshot-removed(string);
/// TRACES: FR-DEV-7
/// `true` on the way down, `false` on the way up.
callback snapshot-compared(string, bool);
}
export component HistoryPanel inherits Rectangle {
@@ -189,6 +300,46 @@ export component HistoryPanel inherits Rectangle {
overflow: elide;
}
// TRACES: FR-DEV-5
// Snapshots above the steps: a snapshot is the state worth keeping
// out of a run of steps, so it sits where the steps lead to.
//
// GESTURE: Keep the photograph as it is now, under a name
// where: Develop
// touch: Type a name in the History panel and press Snapshot
// pointer: Type a name in the History panel and press Snapshot
// why: The history is forgotten with the sitting, on purpose;
// a snapshot is the photographer saying this one should
// not be. It is written into the sidecar as a version
// of the edit, so it survives a restart and reaches the
// other device. Pressing a snapshot puts the photograph
// back to it, as one step that undo takes back whole.
if Develop.enabled: HorizontalLayout {
spacing: Theme.gap-sm;
name := Field {
label: "Snapshot name";
placeholder: "Name this state";
horizontal-stretch: 1;
}
Button {
text: "Snapshot";
clicked => {
Steps.snapshot-taken(name.text);
name.text = "";
}
}
}
for snapshot in Steps.snapshots: SnapshotItem {
data: snapshot;
enabled: Develop.enabled;
restored => { Steps.snapshot-restored(snapshot.id); }
compared(down) => { Steps.snapshot-compared(snapshot.id, down); }
removed => { Steps.snapshot-removed(snapshot.id); }
}
if Develop.enabled && Steps.rows.length > 0: Rectangle {
height: 1px;
background: Theme.rule;
+41
View File
@@ -96,6 +96,8 @@ export struct PartRow {
/// The selection the layer began as. It joins nothing and cannot be
/// removed — removing it is removing the layer.
base: bool,
/// Left out of the build, and kept. The row's eye.
hidden: bool,
}
/// Which part of a gradient a canvas handle drags.
@@ -174,6 +176,7 @@ component MaskEntry inherits Rectangle {
/// Point the tools at one part, change how it joins, or take it out.
callback part-selected(int);
callback part-join-picked(int, int);
callback part-hidden-toggled(int, bool);
callback part-removed(int);
/// Join a fresh painted correction: 0 adds, 1 subtracts.
callback part-added(int);
@@ -210,9 +213,11 @@ component MaskEntry inherits Rectangle {
// where: Develop
// touch: Tap the ring at the head of its row
// pointer: Click the ring at the head of its row
// keys: H, for the selected layer
// why: Disabling a layer is the before-and-after a local
// edit constantly wants, so it is one press away rather
// than inside the row. It is an edit and does take a
// keys: H, for the selected layer
// history step, unlike holding "Before" — the layer
// really is off until it is switched back on.
//
@@ -386,6 +391,33 @@ component MaskEntry inherits Rectangle {
horizontal-stretch: 1;
}
// GESTURE: Leave one part out of a mask, and put it back
// where: Develop
// touch: Tap the ring on the part's row
// pointer: Click the ring on the part's row
// why: The question a correction raises is whether
// it did what it was for — whether the stroke
// filled the shoulder, whether the subtracted
// gradient took only the sky. Removing it
// answers that and loses it. The same ring the
// layer wears, one row down, because it is the
// same question about a smaller thing.
//
// TRACES: FR-DEV-19a
part-eye := TouchArea {
width: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.part-hidden-toggled(i, !part.hidden); }
Label {
text: part.hidden ? "○" : "◉";
emphasised: part-eye.has-hover || !part.hidden;
horizontal-alignment: center;
vertical-alignment: center;
}
}
if !part.base: cut := TouchArea {
width: Theme.touch-target;
mouse-cursor: pointer;
@@ -789,6 +821,12 @@ export global Masking {
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
/// TRACES: FR-DEV-16
/// The same switch for whichever layers are selected, from the keyboard.
/// No id, because the selection is the session's and this file cannot
/// see it; Rust answers by flipping each selected layer through the same
/// path the ring takes, so it is one history step per layer either way.
callback selected-toggled();
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
@@ -809,6 +847,8 @@ export global Masking {
/// TRACES: FR-DEV-19a
callback part-selected(string, int);
callback part-join-picked(string, int, int);
/// Whether the part at this index is left out of the build.
callback part-hidden-toggled(string, int, bool);
callback part-removed(string, int);
callback part-added(string, int);
@@ -1148,6 +1188,7 @@ export component MaskPanel inherits Rectangle {
parts: mask.selected ? Masking.parts : [];
part-selected(i) => { Masking.part-selected(mask.id, i); }
part-join-picked(i, j) => { Masking.part-join-picked(mask.id, i, j); }
part-hidden-toggled(i, on) => { Masking.part-hidden-toggled(mask.id, i, on); }
part-removed(i) => { Masking.part-removed(mask.id, i); }
part-added(j) => { Masking.part-added(mask.id, j); }
shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); }
+124
View File
@@ -136,6 +136,17 @@ export component SettingsPage inherits Rectangle {
in property <string> layout-class;
in property <string> app-version;
/// TRACES: NFR-OPS-1
/// The diagnostics bundle, in its two states. `diagnostics-preview` is
/// empty until the user asks for one; while it is not, the panel shows
/// what would be written and offers the write. `diagnostics-result` is
/// what the last save said — a path, or why it failed.
in property <string> diagnostics-preview;
in property <string> diagnostics-result;
callback diagnostics-prepare();
callback diagnostics-save();
callback diagnostics-discard();
/// TRACES: FR-DSP-8
/// The display showing the canvas, and the colour it is being given.
///
@@ -170,6 +181,13 @@ export component SettingsPage inherits Rectangle {
in property <[string]> timeline-bar-labels;
in property <int> timeline-bars-selected: 0;
callback timeline-bars-picked(int);
/// TRACES: FR-CAT-13 | NFR-R4
/// Whether judgements also go to the `.xmp` beside the original, and how
/// many sidecars the last scan found disagreeing with the catalog.
in property <bool> write-xmp: false;
callback write-xmp-toggled(bool);
in property <int> xmp-conflicts: 0;
callback xmp-reload();
// --- export --------------------------------------------------------
in property <[string]> format-labels;
@@ -900,6 +918,71 @@ export component SettingsPage inherits Rectangle {
}
}
// --- diagnostics -----------------------------------------
//
// TRACES: NFR-OPS-1
// Two presses, never one. The first gathers and shows; the
// second writes. The requirement asks for a preview-and-
// consent step before anything leaves the device, and a
// single button that gathered and saved would be the step
// skipped under the name of convenience. Nothing is sent by
// either press — the file is for the user to attach.
Rectangle {
width: content.column;
height: diagnostics.preferred-height;
diagnostics := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "DIAGNOSTICS"; }
Caption {
text: "The log, any crash records, and the facts "
+ "above, as one text file to attach to a bug "
+ "report. Credentials and file paths are "
+ "removed first, and you see what is in it "
+ "before anything is written.";
wrap: word-wrap;
}
if root.diagnostics-preview == "": Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: "Show what a bundle would contain";
clicked => { root.diagnostics-prepare(); }
}
}
if root.diagnostics-preview != "": Value {
text: root.diagnostics-preview;
wrap: word-wrap;
}
if root.diagnostics-preview != "": HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Button {
text: "Save the bundle";
clicked => { root.diagnostics-save(); }
}
Button {
text: "Don't";
clicked => { root.diagnostics-discard(); }
}
}
if root.diagnostics-result != "": Caption {
text: root.diagnostics-result;
wrap: word-wrap;
}
}
}
// --- naming and destination ------------------------------
//
// Its own panel rather than more of the export one: format and
@@ -915,6 +998,47 @@ export component SettingsPage inherits Rectangle {
PanelHeading { text: "FILES AND METADATA"; }
// TRACES: FR-CAT-13 | NFR-R4
// Reading is not a choice — a sidecar another editor
// wrote is taken in regardless, since reading changes
// nothing in the folder. Writing beside somebody's
// originals is, and it starts off.
Check {
label: "Write ratings, labels and keywords to XMP sidecars";
hint: "Beside the originals, as Lightroom and darktable "
+ "do, so other applications see them. Only those "
+ "fields are written; everything else in an "
+ "existing sidecar is left exactly as it was.";
checked: root.write-xmp;
toggled(on) => { root.write-xmp-toggled(on); }
}
// The offered reload. Shown only while there is
// something to offer: a disagreement between an
// `.xmp` changed elsewhere and what this catalog
// holds, which the automatic pull records rather
// than resolves.
if root.xmp-conflicts > 0: Caption {
text: root.xmp-conflicts
+ (root.xmp-conflicts == 1
? " XMP sidecar disagrees"
: " XMP sidecars disagree")
+ " with this catalog about a rating, label or "
+ "caption. The catalog's values stand until you "
+ "say otherwise.";
wrap: word-wrap;
}
if root.xmp-conflicts > 0: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: "Take the sidecars' values";
clicked => { root.xmp-reload(); }
}
}
TextRow {
label: "Filename template";
hint: "{name} {seq} {date} {dimensions} {preset}";