Found in a bug audit of the just-merged friends-list feature. No crashes
or security holes, but five real state/correctness bugs:
- Host child dying on its own left the share campaign running, so it kept
pushing a now-dead ticket to friends (retrying offline ones forever) and
leaked share_status/met/share_code. The unexpected-exit path now captures
the stderr error, then routes through the full stop_host() teardown
(notably stop_share). (gui/mod.rs pump_host_events)
- on_friend_request downgraded an already-Accepted friend back to
PendingIncoming when they re-sent a request (e.g. after losing their
store). It now stays Accepted and re-confirms. (friends.rs)
- on_friend_accept advanced *any* known peer to Accepted, including a
PendingIncoming one — a peer could mark itself accepted without the local
user's consent. Now only a PendingOutgoing request we sent is honoured.
(friends.rs)
- A ShareCode redelivered by an ACK-loss retry fired a duplicate desktop
notification. push_notice now reports whether the code is new/changed and
only then toasts. (gui/mod.rs)
- An inbound control message could be delayed up to IO_TIMEOUT on a degraded
link because handle() awaited the sender's close before forwarding it.
Forward to the UI first, then await close so the ACK still flushes.
(control.rs)
Adds two friends-store transition tests (accept ignores a pending-incoming
peer; request doesn't downgrade an accepted friend). 47 gui / 8 headless
tests pass, clippy + fmt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build the friends feature on top of the phase-2 control plane: you can
now befriend someone you've connected with and manage a contacts list.
- common/friends.rs: a persisted FriendStore in its own friends.toml
(kept out of config.toml so a headless --reconfigure can't clobber it,
same as identity.key). Friends are keyed by stable control EndpointId;
state is PendingOutgoing / PendingIncoming / Accepted. The handshake
transitions (on_friend_request → mutual-match detection, on_friend_
accept) are pure and unit-tested.
- gui/code.rs: the bootstrap. The GUI host wraps its share code as
`pixelpassF1:<control-id>.<ticket>` so a viewer learns the host's
stable id; unwrap is lenient, so a bare/CLI ticket still works (no
friend offer). The video/streaming path is untouched.
- presence service gains an outbound path (unbounded channel → per-msg
send tasks) and exposes our control id for wrapping codes.
- gui wiring: on connect, the viewer announces itself to the host with a
Hello (carrying our display name); the host replies once, so both ends
learn each other and an "Add friend" offer appears on the running
host/view screens. Incoming requests/accepts/declines fold into the
store with desktop notifications. New Friends screen (accept/decline/
remove, edit your display name, see your id) reachable from the menu,
which shows a pending-request count. New [gui] display_name setting,
seeded from $USER.
Verified: friends store + handshake transitions covered by unit tests
(7); code wrap/unwrap round-trips (4); the control loopback still passes;
the live GUI starts clean with the presence endpoint online. fmt +
clippy clean on both features; 41 gui + 8 headless tests pass. The full
two-party UX (connect → mutual add → persisted) wants a cross-machine
manual check, as usual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stand up the friends control plane: a persistent-identity iroh endpoint
that's online for the whole GUI session, separate from the ephemeral
video sessions, ready to carry friend requests and pushed share-codes.
Identity split by plane (common/endpoint.rs): the video plane (host/
viewer) goes back to ephemeral per-session keypairs, while the new
bind_control() binds with the machine's persistent identity. They must
differ — the GUI's control endpoint and a host's video endpoint can be
live at once, and iroh routes by EndpointId, so a shared id would make
relay delivery ambiguous. Bonus: a screen-share now leaks no stable id.
common/control.rs — the protocol: a ControlMsg enum (Hello / Friend
Request / FriendAccept / FriendDecline / ShareCode) with one-message-
per-connection framing (EOF-delimited JSON) and a one-byte ACK the
receiver returns only after a successful parse, so send() gets a real
delivered/failed signal (the basis for the later code-push queue). The
sender id is taken from the connection's verified remote key, never the
payload. send() takes impl Into<EndpointAddr> so production dials a bare
EndpointId (discovery resolves it) while tests use a full addr.
gui/presence.rs — the service: a dedicated thread + current-thread tokio
runtime (mirroring the tray) binds the control endpoint and runs the
accept loop, bridging inbound messages to a std mpsc the UI drains each
tick and pinging the Waker so they land even while hidden to the tray.
The whole friends stack (identity, control, CONTROL_ALPN, bind_control)
is gated behind the `gui` feature — a headless CLI host runs no presence
service — keeping the headless build lean and warning-free.
Verified: loopback test delivers a FriendRequest across two real iroh
endpoints with the correct authenticated sender id; the live GUI binds
its control endpoint on launch under the persistent identity. fmt +
clippy clean on both feature sets; headless and gui test suites pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>