feat(visaged): show the user what the camera sees while enrolling - #115
Merged
Merged
Conversation
Enrollment is blind. The user is told to look at a lens, four captures happen, and nothing says whether they were too dark, off-centre, or out of frame. When a capture fails they are given no way to tell why, and the retry is the same guess again. This adds a PreviewFrame signal carrying downscaled grayscale frames while an enrollment is running. The shape, and why it is this shape There is deliberately NO method to ask for a camera frame. The only producer is an Enroll the caller started; the frames are unicast to that caller's own bus name rather than broadcast; and the channel exists for exactly the duration of that call. So a preview cannot be used as a general camera tap, and the feature adds no new privilege boundary — it inherits Enroll's existing root-only check rather than introducing a second one to keep in sync. Frames are downscaled in the engine thread, before crossing any channel. There is no code path on which a full-resolution capture reaches the bus. Dark frames are emitted too The capture loop discards frames it judges too dark, and those are exactly the ones worth showing: "too dark" is the most actionable thing a user can be told, and it is only knowable inside that loop. They arrive with is_dark set and no contrast enhancement, so a client can label them rather than silently showing nothing. The auth path runs the same code capture_frames now delegates to capture_frames_observed with a no-op closure, so verify — the authentication path — executes the identical function body. The two paths cannot drift apart, because there is only one. observe() must not block: it runs between buffer dequeues. Sends are try_send and dropped under backpressure. A slow or absent preview consumer must never slow down or fail an enrollment. 160px is a security parameter, not a tuning knob The preview is allowed out of a biometric daemon precisely because it is too coarse to be a useful capture. That argument only holds while the number is small, so the number is pinned by a literal with a message saying that raising it needs a threat-model review. That test exists because the obvious one does not work. Asserting `longest_edge <= PREVIEW_MAX_EDGE` compares the downscaler against the very constant at issue: it passes just as happily at 2000px, and the security argument would evaporate with nothing going red. Measured, not assumed — with the constant raised to 2000, five of the six tests still pass and only the literal pin fails. Verified Six hermetic tests, no hardware, no new dependencies. Proven to fail in both directions, against a positive control so an empty run cannot masquerade as a pass: unmodified 6 passed PREVIEW_MAX_EDGE = 2000 1 failed (only the literal pin) downscaler returns source as-is 4 failed restored 6 passed Full repo gate, exit codes read from unpiped runs: fmt --check 0, clippy --workspace --all-targets -D warnings 0, test --workspace 0 at 99 passed. Signed-off-by: ccross <cescross2@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Enrollment is blind. The user is told to look at a lens, four captures happen, and nothing says whether they were too dark, off-centre or out of frame. When a capture fails there is no way to tell why, and the retry is the same guess again.
This adds a
PreviewFramesignal carrying downscaled grayscale frames while an enrollment is running.The shape, and why it is this shape
There is deliberately no method to ask for a camera frame. The only producer is an
Enrollthe caller started; frames are unicast to that caller's own bus name rather than broadcast; and the channel exists for exactly the duration of that call.So the preview cannot be used as a general camera tap, and it adds no new privilege boundary — it inherits
Enroll's existing root-only check rather than introducing a second one to keep in sync.Frames are downscaled in the engine thread, before crossing any channel. There is no code path on which a full-resolution capture reaches the bus.
Dark frames are emitted too
The capture loop discards frames it judges too dark — and those are exactly the ones worth showing. "Too dark" is the most actionable thing a user can be told, and it is only knowable inside that loop. They arrive with
is_darkset and no contrast enhancement, so a client can label them rather than silently showing nothing.The authentication path runs the same code
capture_framesnow delegates tocapture_frames_observedwith a no-op closure, soverifyexecutes the identical function body. The two paths cannot drift apart because there is only one.observe()must not block — it runs between buffer dequeues. Sends aretry_sendand dropped under backpressure: a slow or absent preview consumer must never slow down or fail an enrollment.160px is a security parameter, not a tuning knob
The preview is allowed out of a biometric daemon precisely because it is too coarse to be a useful capture. That argument only holds while the number stays small, so it is pinned by a literal whose failure message says raising it needs a threat-model review.
That test exists because the obvious one does not work. Asserting
longest_edge <= PREVIEW_MAX_EDGEcompares the downscaler against the very constant at issue — it passes just as happily at 2000px, and the security argument would evaporate with nothing going red. Measured, not assumed: with the constant raised to 2000, five of the six tests still pass and only the literal pin fails.Verified
Six hermetic tests. No hardware, no new dependencies. Proven to fail in both directions, against a positive control so an empty run cannot masquerade as a pass:
PREVIEW_MAX_EDGE = 2000Full repo gate, exit codes from unpiped runs:
fmt --check0,clippy --workspace --all-targets -D warnings0,test --workspace0 at 99 passed.For a reviewer — three judgement calls, not facts
verifynow routes throughcapture_frames_observed. Behaviour is byte-identical — same body, no-op closure — but it is a change to the auth path's call graph and deserves an eye rather than my assurance.Not included
No client consumes this yet. The ratatui and pure-Rust-GUI prototypes are the next piece and both read this same signal, which is the point — the comparison is between interaction models, with the data path held constant.
🤖 Generated with Claude Code