Commit Graph
8 Commits
Author SHA1 Message Date
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 509c3a3e96 Merge: a log that survives the process, so the tablet can be debugged
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	apps/darkroom-android/Cargo.toml
#	apps/darkroom-desktop/Cargo.toml
#	platform/dr-plat/src/lib.rs
2026-08-30 13:46:06 +02:00
dtourolle 465f5a7ffa Keep the log after the session that produced it
Everything this application knew about a failure went to stderr on the
desktop and to logcat on Android, and both are gone the moment the
terminal closes or the ring buffer wraps. That is fine when the person
debugging is sitting at the machine. It is useless for the case NFR-OPS-1
actually describes, and the one Android makes normal: somebody reproduces
a bug on a tablet, and then sends us a file.

A large amount of Android behaviour has never run on a device — image
intents read over JNI, an ExportProvider, a class loaded through the
activity's class loader, memory-pressure eviction, lost-root recovery —
and the single most likely failure of the lot, the activity's loader not
resolving our classes from android_main's thread, produces one line that
scrolls past. That line is now in a file, with the thread that emitted it
named beside it.

dr_plat::state answers "where does this platform keep state for this
app": $XDG_STATE_HOME/darkroom on Linux, and on Android whatever the
entry point declares. Separate from configuration and from the catalog
for the reason XDG separates them — state is the thing nobody backs up
and the user may delete without consequence.

dr_plat::diagnostics is the sink. Two files of 4 MiB, so the worst case
is a number rather than a discovery on a full phone; one line per write
with no BufWriter anywhere, because on Android processes are killed
rather than ended and a buffered log loses exactly the line it was kept
for; and redaction applied at the sink rather than at the call sites,
since a rule every author has to remember is not a rule. It tees the
platform's own logger rather than replacing it, so logcat is unchanged —
losing that while debugging would have made this a downgrade.

Android logs to external_data_path, not internal. Both are app-private
and both survive backgrounding; what separates them is that
/data/data/<pkg>/files needs run-as against a debuggable build to read
and /sdcard/Android/data/<pkg>/files is a plain adb pull from any build.
A log nobody can retrieve is not a diagnostic. The consequence is that
anyone holding the tablet can read it, which is why the redaction is
where it is, and why configuration stays on internal_data_path.

What is redacted is what NFR-SEC-2 and NFR-OPS-1 name: credentials and
tokens, found by the keyword that nearly always sits next to them, plus
the two forms that carry one with no keyword at all — an Authorization
scheme and a URL's userinfo. What is deliberately not redacted is
filesystem paths and the names of the user's photographs. They are in
neither requirement's list, and "failed to decode <redacted>" is not a
diagnostic; the preview-and-consent step NFR-OPS-1 asks for governs those
better than scrubbing would, because it lets the user look.

The over-redaction failure is tested as carefully as the under-redaction
one. A scrubber that eats "using basic sRGB as the fallback" makes a log
useless without ever being caught.
2026-08-30 10:40:54 +02:00
dtourolleandClaude Opus 5 35954dfa1d Write a panic down where it can still be read, with nothing in it that names the user
NFR-OPS-2 is two sentences — local crash capture always, upload only on
explicit opt-in — and what existed was one `log::error!` in the Android entry
point and nothing at all on desktop. So a panic on desktop went to stderr and
died with the terminal, and a panic on Android went to a logcat ring buffer
that is gone long before anyone reports anything. What the user saw either way
was a job that stopped or a control that went dead, with nothing to send.

That matters more here than it would in most applications, because
NFR-ARCH-4 says no worker error may panic the process and the code is written
that way: errors are typed and attached to the image or job they belong to. A
panic is therefore by construction a bug — an invariant this codebase believed
and got wrong — and it was the one class of failure with no trace.

`dr_plat::crash` writes a record to the XDG state directory: version, time,
os, arch, thread, panic location, message, backtrace. In dr-plat rather than
in either entry point because "where does this platform let an application
keep state" is a platform question, and Android's answer is neither XDG nor
`temp_dir` — `set_state_dir` takes it from `internal_data_path`, the same
place `dr_sync::account::set_data_dir` gets its answer. The hook resolves the
directory when it fires rather than when it is installed, which is what lets
it go in before everything else and cover the startup it would otherwise miss.

**The content rule is the substance of this, not the plumbing.** NFR-SEC-2
forbids credentials in logs and plain files; the same reasoning applies with
more force to what this application is actually about, because a user's
library is private and so is its shape. `/home/anna/Photos/2019 Divorce/` says
something about a person, and a crash record is exactly the file someone
attaches to a bug report while trying to be helpful. So `redact` runs over the
message *and* the backtrace, and is deliberately blunt: anything containing a
slash goes, `content://` and `primary:DCIM/...` included, since SAF names a
library just as precisely as a path does; anything beside a word like
`password` goes; a long opaque run with letters and digits in it goes, which
is the shape of an app password nobody labelled. The one exception is a `.rs`
path, which keeps its basename — a backtrace with no filenames is close to
useless and `library.rs:1270` says nothing about anybody.

Over-redaction costs legibility. Under-redaction costs a user something they
cannot take back. Those are not comparable, so the boundary is not the place
to be clever.

NFR-SEC-5 — face data never in a crash report, under any configuration — is
met structurally rather than by filtering: this module reads no catalog, opens
no image, touches no account. A record is assembled from the panic hook's own
arguments and `std::env::consts`, and there is no code path from here to an
embedding. The message length cap is the backstop for a payload some other
module formatted something large into.

stderr is the one surface that still sees the message unredacted, deliberately:
the previous hook is chained rather than replaced, so a developer watching a
terminal does not lose the panic because the application started writing files.
It is ephemeral, local, and never attached to a report.

**No upload path, and not half of one** — no endpoint, no queue, no "send this
later" flag. Opt-in upload needs a server to receive it and a consent flow
stating what leaves the device (NFR-SEC-4, and the preview-and-consent step
NFR-OPS-1 requires of the diagnostics bundle). Neither exists, and a transport
built ahead of its consent is the shape of thing that later gets switched on
by default.

Leaves NFR-OPS-1 cheaper by three things it will want unchanged: `state_dir`
(the log belongs at `state_dir()/log` beside `crash/`, so the diagnostics
bundle has one directory to collect), `redact` (NFR-OPS-1's "automatic
redaction of credentials and tokens" is this function), and `prune` (a
size-capped rotation is this, counting bytes instead of files).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:34:12 +02:00
dtourolleandClaude Sonnet 5 d78c9a27fd Exit desktop process immediately after window close
window.run() returning unwinds through main, which races a still-live
zbus/keyring background thread's TLS teardown and aborts with
"thread local ... during or after destruction". Skip that unwind with
a hard exit once the run succeeds; dr_ui::run itself is left alone
since the Android entry point also calls it and must not be exited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:20:48 +02:00
dtourolle 9717e59909 Add RAW decode and a working image viewer
dr-decode exposes four entry points rather than one decode, because
callers differ sharply in what they need (ARCH §3.2): culling wants a
preview, the grid wants metadata, only develop and export touch sensor
data. Fusing them forces a full decode where a header read suffices,
which is why Lightroom stalls ~2s per image while culling.

Smoke-tested against 1,852 real Canon CR2 files (EOS 6D, ~27MB each):

  metadata      0.2ms   from a 256KB header read, no full decode
  preview     ~250ms   5472x3648, downscaled to 2048 for display
  jpeg          3.0ms

Two findings worth recording:

rawler 0.7.2's CR2 decoder implements only full_image; thumbnail_image
and preview_image are unimplemented trait defaults returning None. So
every rung of the preview ladder resolves to a full-resolution decode at
~250ms — 5x over NFR-P13's 50ms budget. CR2 does carry smaller IFDs, so
the fix is our own IFD walk or an upstream contribution. The ladder is
written now so that fixing it is a decoder change, not a change to every
caller. Recorded in milestone-v0.1 risks.

Preview.downscale_to bounds memory: a 5472x3648 RGBA preview is 79.8MB,
which exhausts a phone's budget after a handful of images. Box-filtered
so downscaled thumbnails do not alias.

Also fixed a RefCell double-borrow that panicked on first navigation —
`*x.borrow_mut() = *x.borrow() + 1` holds both borrows at once. Verified
with 10,000 programmatic navigations.

58 tests passing. Traceability 20.3% (29/143).
2026-08-09 08:28:26 +02:00
dtourolle 0f202fd3f9 Add requirements traceability gate and Gitea pipelines
Ports JellyTau's traceability tooling to Rust, carrying across the bug it
was repaired for. That gate divided a traced count by frozen literal
denominators; the requirements file outgrew them and it reported 158%
coverage, so it could never fail its own threshold.

Two rules, both enforced by the extractor's own tests:

  - denominators parsed from docs/requirements.md at run time
  - coverage is |traced ∩ defined| / |defined|, never a raw traced count

The gate additionally fails hard on a misconfigured run — zero
requirements parsed or zero files scanned — rather than reporting a
plausible 0%, and on any orphan tag naming a requirement that does not
exist.

Adapted for DarkRoom: IDs are FR-CAT-1 / NFR-P13 / FR-DEV-3a shapes
rather than JellyTau's fixed three digits, and decisions (D), spikes (S),
milestone items (M) and test ids remain taggable while being excluded
from the denominator — counting them inflated it by 25.

Also adds dr-sync: the RemoteBackend trait and capability model, so the
Nextcloud connector is one implementation rather than the only shape the
engine understands. No mature Nextcloud crate exists (reqwest_dav is too
thin), so the connector will be hand-rolled over reqwest per D7.

Gitea workflows follow the same style: containerised, commented with the
reasoning, desktop and Android on every push, plus a CI check that no
core/ crate depends on the UI toolkit (ARCH §6.5a).

Coverage today: 13.3% (19/143). 50 tests passing.
2026-08-09 08:01:32 +02:00
dtourolle 82a5e21ec6 Initial workspace: GPU context, compute pass, adaptive Slint shell
Establishes the v0.1 foundations on both platforms:

- dr-types: SourceRef (never a filesystem path — Android SAF has none),
  Format, Availability, Validator with ETag quote normalisation
- dr-gpu: wgpu device, compute pass writing a storage texture, resize
- dr-ui: Slint shell with FR-UI-1 adaptive layout, computed in Rust to
  avoid a binding loop
- docker/android: pinned toolchain, verified producing API 28 ARM binaries

Measured the cost of the temporary CPU readback path (dr-gpu bench):
compute is 0.06-0.28ms across sizes while readback is 0.63-7.43ms, so
readback is 90-96% of frame time and scales with area. Recorded in
ARCH §6.1 — this is why spike S1 is the priority.

Mitigations pending S1: reuse the staging buffer, apply at most one
resize per frame, and cap render resolution at 2048 on the long edge.

10 tests passing; core crates cross-compile for aarch64-linux-android.
2026-08-09 07:42:05 +02:00