feat(friends): friend store, mutual handshake, and Friends UI (phase 3)

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>
This commit is contained in:
2026-05-30 16:42:14 -04:00
co-authored by Claude Opus 4.8
parent f5d0333366
commit 9e839ca452
7 changed files with 815 additions and 30 deletions
-4
View File
@@ -82,10 +82,6 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
/// `peer` is usually a bare [`EndpointId`] — friends store only the stable id,
/// and n0 DNS discovery resolves it to a live address. The full [`EndpointAddr`]
/// form exists for callers that already hold one (and for hermetic tests).
//
// Lands ahead of its caller: the outbound paths (friend requests, code pushes)
// are wired into the GUI in Phase 3/4. The loopback test exercises it now.
#[allow(dead_code)]
pub async fn send(
endpoint: &Endpoint,
peer: impl Into<EndpointAddr>,