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
parent f5d0333366
commit 9e839ca452
7 changed files with 815 additions and 30 deletions
+14
View File
@@ -36,6 +36,10 @@ pub struct GuiSettings {
/// `~/.config/pixelpass/themes/`). Defaults to the built-in Default Dark.
#[serde(default = "default_theme")]
pub theme: String,
/// The display name shown to friends (in requests and shared codes).
/// Seeded from the login name; editable in Settings.
#[serde(default = "default_display_name")]
pub display_name: String,
}
impl Default for GuiSettings {
@@ -44,6 +48,7 @@ impl Default for GuiSettings {
close_to_tray: false,
show_qr: true,
theme: default_theme(),
display_name: default_display_name(),
}
}
}
@@ -56,6 +61,15 @@ fn default_theme() -> String {
"Default Dark".to_string()
}
/// Seed the friends display name from the login name, falling back to a
/// generic label when `$USER` isn't set.
fn default_display_name() -> String {
std::env::var("USER")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "PixelPass user".to_string())
}
/// Result of the first-run upstream measurement.
///
/// `status = "unmeasured"` means we've never asked the user — show the