From 9e839ca452bbe066aa65c672794491bd5ea8ca31 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 30 May 2026 16:42:14 -0400 Subject: [PATCH] feat(friends): friend store, mutual handshake, and Friends UI (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:.` 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 --- src/common/config.rs | 14 ++ src/common/control.rs | 4 - src/common/friends.rs | 256 +++++++++++++++++++++++++ src/common/mod.rs | 2 + src/gui/code.rs | 88 +++++++++ src/gui/mod.rs | 422 +++++++++++++++++++++++++++++++++++++++--- src/gui/presence.rs | 59 +++++- 7 files changed, 815 insertions(+), 30 deletions(-) create mode 100644 src/common/friends.rs create mode 100644 src/gui/code.rs diff --git a/src/common/config.rs b/src/common/config.rs index 57a2c31..28d9b0a 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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 diff --git a/src/common/control.rs b/src/common/control.rs index 161a25e..e6d360a 100644 --- a/src/common/control.rs +++ b/src/common/control.rs @@ -82,10 +82,6 @@ fn decode(bytes: &[u8]) -> Result { /// `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, diff --git a/src/common/friends.rs b/src/common/friends.rs new file mode 100644 index 0000000..b4aa8d7 --- /dev/null +++ b/src/common/friends.rs @@ -0,0 +1,256 @@ +//! Persistent friends store at `~/.config/pixelpass/friends.toml`. +//! +//! Kept in its own file rather than a `[friends]` section of `config.toml` so +//! the headless CLI — which never manages friends and would round-trip the +//! config without this knowledge — can't drop the list on a `--reconfigure`. +//! Same reasoning as the separate `identity.key`. +//! +//! A friend is identified by their stable control-plane [`EndpointId`] (the id +//! from [`super::endpoint::bind_control`]). `EndpointId` serialises as its +//! string form in TOML, so the file is human-readable and hand-editable. + +use anyhow::{Context, Result}; +use iroh::EndpointId; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +/// Where a friendship sits in the mutual-consent handshake. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FriendState { + /// We've sent them a request and are waiting for them to accept. + PendingOutgoing, + /// They've requested us; waiting for the local user to accept or decline. + PendingIncoming, + /// Both sides have agreed — a real friend. + Accepted, +} + +/// One entry in the friends list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Friend { + pub id: EndpointId, + /// Display name — seeded from the name the peer reported, locally editable. + pub name: String, + pub state: FriendState, +} + +/// The persisted friends list. Serialises as a TOML array of tables +/// (`[[friends]]`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FriendStore { + #[serde(default)] + pub friends: Vec, +} + +/// Returns `~/.config/pixelpass/friends.toml`. Shares the config directory with +/// [`super::config`]; the parent is created on save. +pub fn friends_path() -> Result { + Ok(super::config::config_path()? + .parent() + .context("config path has no parent directory")? + .join("friends.toml")) +} + +/// Load the store, or a default (empty) one if the file doesn't exist yet. +/// Parse errors bubble up so a hand-edit being debugged isn't silently +/// overwritten. +pub fn load() -> Result { + let path = friends_path()?; + match fs::read_to_string(&path) { + Ok(s) => toml::from_str(&s).with_context(|| format!("failed to parse {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()), + Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), + } +} + +impl FriendStore { + /// Atomic write via tempfile-in-same-dir + rename (mirrors + /// [`super::config::save`]). + pub fn save(&self) -> Result<()> { + let path = friends_path()?; + let parent = path + .parent() + .context("friends path has no parent directory")?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + + let serialized = toml::to_string_pretty(self).context("failed to serialize friends")?; + let tmp = parent.join(format!(".friends.toml.tmp.{}", std::process::id())); + { + let mut f = fs::File::create(&tmp) + .with_context(|| format!("failed to create {}", tmp.display()))?; + f.write_all(serialized.as_bytes()) + .with_context(|| format!("failed to write {}", tmp.display()))?; + f.sync_all().ok(); + } + fs::rename(&tmp, &path) + .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?; + Ok(()) + } + + pub fn find(&self, id: &EndpointId) -> Option<&Friend> { + self.friends.iter().find(|f| &f.id == id) + } + + pub fn find_mut(&mut self, id: &EndpointId) -> Option<&mut Friend> { + self.friends.iter_mut().find(|f| &f.id == id) + } + + /// True iff this id is a fully-accepted friend — the gate the code-push + /// (Phase 4) and "is this a known friend?" checks use. + pub fn is_accepted(&self, id: &EndpointId) -> bool { + matches!( + self.find(id), + Some(Friend { + state: FriendState::Accepted, + .. + }) + ) + } + + /// Insert a new friend, or update an existing one's `name`/`state` in place. + /// Returns a mutable reference to the stored entry. + pub fn upsert(&mut self, id: EndpointId, name: String, state: FriendState) -> &mut Friend { + if let Some(idx) = self.friends.iter().position(|f| f.id == id) { + let f = &mut self.friends[idx]; + f.name = name; + f.state = state; + f + } else { + self.friends.push(Friend { id, name, state }); + self.friends.last_mut().expect("just pushed") + } + } + + /// Remove a friend by id. Returns whether an entry was removed. + pub fn remove(&mut self, id: &EndpointId) -> bool { + let before = self.friends.len(); + self.friends.retain(|f| &f.id != id); + self.friends.len() != before + } + + /// Apply an inbound friend request. Returns `true` if it *completes a mutual + /// match* — we'd already sent them one, so they're now [`Accepted`] and the + /// caller should reply with a `FriendAccept`. Otherwise it's recorded as + /// [`PendingIncoming`] for the user to act on and `false` is returned. + /// + /// [`Accepted`]: FriendState::Accepted + /// [`PendingIncoming`]: FriendState::PendingIncoming + pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool { + if matches!( + self.find(&id).map(|f| f.state), + Some(FriendState::PendingOutgoing) + ) { + self.upsert(id, name, FriendState::Accepted); + true + } else { + self.upsert(id, name, FriendState::PendingIncoming); + false + } + } + + /// Apply an inbound acceptance of a request we sent. Returns `true` if it + /// advanced a friendship to [`Accepted`] (i.e. we actually knew this peer); + /// an accept from a stranger is ignored. + /// + /// [`Accepted`]: FriendState::Accepted + pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool { + if self.find(&id).is_some() { + self.upsert(id, name, FriendState::Accepted); + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_id() -> EndpointId { + iroh::SecretKey::generate().public() + } + + #[test] + fn round_trips_through_toml() { + let mut store = FriendStore::default(); + store.upsert(sample_id(), "Alice".into(), FriendState::Accepted); + store.upsert(sample_id(), "Bob".into(), FriendState::PendingIncoming); + + let toml = toml::to_string_pretty(&store).unwrap(); + let back: FriendStore = toml::from_str(&toml).unwrap(); + assert_eq!(back.friends, store.friends); + } + + #[test] + fn upsert_updates_in_place() { + let mut store = FriendStore::default(); + let id = sample_id(); + store.upsert(id, "Old".into(), FriendState::PendingOutgoing); + store.upsert(id, "New".into(), FriendState::Accepted); + assert_eq!(store.friends.len(), 1); + let f = store.find(&id).unwrap(); + assert_eq!(f.name, "New"); + assert_eq!(f.state, FriendState::Accepted); + } + + #[test] + fn is_accepted_only_for_accepted_state() { + let mut store = FriendStore::default(); + let pending = sample_id(); + let friend = sample_id(); + store.upsert(pending, "P".into(), FriendState::PendingOutgoing); + store.upsert(friend, "F".into(), FriendState::Accepted); + assert!(!store.is_accepted(&pending)); + assert!(store.is_accepted(&friend)); + assert!(!store.is_accepted(&sample_id())); + } + + #[test] + fn remove_reports_whether_present() { + let mut store = FriendStore::default(); + let id = sample_id(); + store.upsert(id, "X".into(), FriendState::Accepted); + assert!(store.remove(&id)); + assert!(!store.remove(&id)); + assert!(store.friends.is_empty()); + } + + #[test] + fn incoming_request_from_stranger_is_pending() { + let mut store = FriendStore::default(); + let id = sample_id(); + let mutual = store.on_friend_request(id, "Stranger".into()); + assert!(!mutual); + assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming); + } + + #[test] + fn incoming_request_matching_our_outgoing_is_mutual() { + let mut store = FriendStore::default(); + let id = sample_id(); + // We asked them first… + store.upsert(id, "Pal".into(), FriendState::PendingOutgoing); + // …then their request arrives — that's a mutual match. + let mutual = store.on_friend_request(id, "Pal".into()); + assert!(mutual); + assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted); + } + + #[test] + fn accept_advances_known_peer_only() { + let mut store = FriendStore::default(); + let known = sample_id(); + store.upsert(known, "Known".into(), FriendState::PendingOutgoing); + assert!(store.on_friend_accept(known, "Known".into())); + assert_eq!(store.find(&known).unwrap().state, FriendState::Accepted); + // An accept from someone we never asked is ignored. + let stranger = sample_id(); + assert!(!store.on_friend_accept(stranger, "Nope".into())); + assert!(store.find(&stranger).is_none()); + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index 0fefb0a..1335ddc 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -10,6 +10,8 @@ pub mod deps; pub mod display; pub mod endpoint; #[cfg(feature = "gui")] +pub mod friends; +#[cfg(feature = "gui")] pub mod identity; pub mod output; pub mod process; diff --git a/src/gui/code.rs b/src/gui/code.rs new file mode 100644 index 0000000..eae7f28 --- /dev/null +++ b/src/gui/code.rs @@ -0,0 +1,88 @@ +//! Share-code wrapping: carrying the host's stable friend id alongside the +//! one-shot video ticket. +//! +//! A bare video ticket identifies only the host's *ephemeral* video endpoint, +//! so two people who meet over one can't learn each other's stable friend id — +//! the thing the friends system needs. The GUI host therefore wraps its ticket +//! with its control-plane [`EndpointId`]; the viewer unwraps it, dials the +//! video ticket as before, and now also knows who to befriend (and announces +//! itself back over the control plane so the host learns the viewer in turn). +//! +//! Format: `pixelpassF1:.`. Both the id and the +//! ticket are base32 text with no `.`, so a single `.` separator is +//! unambiguous. [`unwrap`] is lenient: anything without the prefix is treated +//! as a bare ticket, so a plain CLI ticket pasted into the GUI still works (it +//! just offers no friend option). The host name isn't carried here — the +//! viewer's announcement triggers a name exchange over the control plane. + +use std::str::FromStr; + +use iroh::EndpointId; + +/// Prefix marking a wrapped friend code. The `F1` is the wrap-format version, +/// bumped if the layout ever changes. +const MAGIC: &str = "pixelpassF1:"; + +/// Wrap a bare ticket with the host's control id, for display/copy/QR. +pub fn wrap(host_id: EndpointId, ticket: &str) -> String { + format!("{MAGIC}{host_id}.{ticket}") +} + +/// Split an input into `(host control id if it was a wrapped code, bare +/// ticket)`. A bare or unrecognised input yields `(None, trimmed input)` so the +/// viewer path stays identical to before for plain tickets. +pub fn unwrap(code: &str) -> (Option, String) { + let code = code.trim(); + if let Some(rest) = code.strip_prefix(MAGIC) + && let Some((id_str, ticket)) = rest.split_once('.') + && let Ok(id) = EndpointId::from_str(id_str) + && !ticket.is_empty() + { + return (Some(id), ticket.to_string()); + } + (None, code.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_id() -> EndpointId { + iroh::SecretKey::generate().public() + } + + #[test] + fn wrap_unwrap_round_trips() { + let id = sample_id(); + let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm"; + let code = wrap(id, ticket); + let (got_id, got_ticket) = unwrap(&code); + assert_eq!(got_id, Some(id)); + assert_eq!(got_ticket, ticket); + } + + #[test] + fn bare_ticket_passes_through() { + let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm"; + let (id, got) = unwrap(ticket); + assert_eq!(id, None); + assert_eq!(got, ticket); + } + + #[test] + fn trims_surrounding_whitespace() { + let ticket = "endpointaabwxjex"; + let (id, got) = unwrap(&format!(" {} ", wrap(sample_id(), ticket))); + assert!(id.is_some()); + assert_eq!(got, ticket); + } + + #[test] + fn malformed_wrapped_code_falls_back_to_bare() { + // Prefix present but the id isn't a valid EndpointId → treat the whole + // thing as a (doomed) bare ticket rather than panicking. + let (id, got) = unwrap("pixelpassF1:not-an-id.endpointaa"); + assert_eq!(id, None); + assert_eq!(got, "pixelpassF1:not-an-id.endpointaa"); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 399f536..0fe64b3 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -37,6 +37,7 @@ //! dropped and no egui frame is running. mod child; +mod code; mod presence; mod theme; mod tray; @@ -68,6 +69,8 @@ use winit::window::{Window, WindowAttributes, WindowId}; use self::child::{ChildEvent, ChildProc}; use self::presence::PresenceHandle; use self::tray::{TrayAction, TrayHandle, TrayStatus}; +use crate::common::control::ControlMsg; +use crate::common::friends::FriendState; /// Initial / minimum window size, in logical points. Initial height fits the /// host screen (ticket + Copy + QR + Stop) without needing to scroll on a 1080p @@ -614,6 +617,11 @@ pub fn run(relay: Option) -> anyhow::Result<()> { let names = theme::all_themes().into_iter().map(|t| t.name).collect(); let draft = active.clone(); + let friends = crate::common::friends::load().unwrap_or_else(|e| { + tracing::warn!("failed to load friends list: {e:#}"); + Default::default() + }); + let state = PixelPassApp { screen: Screen::default(), host: HostState::default(), @@ -632,6 +640,9 @@ pub fn run(relay: Option) -> anyhow::Result<()> { }, waker, presence, + friends, + display_name: gui_settings.display_name, + met: Vec::new(), }; let mut app = App { state, @@ -688,6 +699,14 @@ fn short_id(id: &str) -> String { } } +/// A `Hello` control message carrying our display name — the self-introduction +/// a viewer sends the host on connect, and the host's reply. +fn control_hello(name: &str) -> ControlMsg { + ControlMsg::Hello { + name: name.to_string(), + } +} + /// Fire a desktop notification, on a detached thread so the D-Bus round-trip /// can't stall the egui frame. Best-effort: with no notification daemon it /// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this @@ -724,6 +743,14 @@ fn persist_show_qr(value: bool) { } } +fn persist_display_name(value: &str) { + let mut cfg = crate::common::config::load().unwrap_or_default(); + cfg.gui.display_name = value.to_string(); + if let Err(e) = crate::common::config::save(&cfg) { + tracing::warn!("failed to save settings: {e}"); + } +} + fn persist_theme(name: &str) { let mut cfg = crate::common::config::load().unwrap_or_default(); cfg.gui.theme = name.to_string(); @@ -739,6 +766,7 @@ enum Screen { Menu, Host, Viewer, + Friends, Settings, Shortcuts, } @@ -813,7 +841,13 @@ struct HostState { window: bool, // running session + accumulated live state proc: Option, + /// The bare video ticket from the child (used for the host-id fingerprint + /// line). The copy/QR/display use [`HostState::share_code`] instead. ticket: Option, + /// The share code shown/copied/QR'd: the ticket wrapped with our control id + /// (see [`code::wrap`]) when the presence service is up, else the bare + /// ticket. Wrapping is what lets a viewer offer to befriend the host. + share_code: Option, info: Option, active: u32, max: u32, @@ -855,6 +889,10 @@ struct ViewerState { /// Short endpoint id we're dialing, decoded from the ticket at Connect. /// Shown in the "Connecting to …" line so a dead host is identifiable. connecting_to: Option, + /// The host's stable control id, if the pasted code was a wrapped friend + /// code. Lets us announce ourselves to the host (so both ends can befriend) + /// once connected. `None` for a bare/CLI ticket. + host_control_id: Option, /// Set when the View screen opens so the code field grabs focus once /// (cleared on use, so it doesn't steal focus every frame). focus_ticket: bool, @@ -890,6 +928,20 @@ struct PixelPassApp { /// if it couldn't start (no identity), in which case friends features are /// simply absent. presence: Option, + /// The persisted friends list (mutual-consent contacts). + friends: crate::common::friends::FriendStore, + /// Our display name, shown to friends. Persisted in `[gui] display_name`. + display_name: String, + /// Peers met this session (over a connection) who aren't yet in the friends + /// list — drives the "add friend" offer. Session-scoped, not persisted. + met: Vec, +} + +/// A peer encountered this session but not yet befriended. +struct MetPeer { + id: iroh::EndpointId, + /// Their reported display name (a short id placeholder until a name arrives). + name: String, } /// The active theme plus the Settings picker/editor working state. @@ -970,16 +1022,290 @@ impl PixelPassApp { self.sync_tray_status(); } - /// Drain inbound control-plane messages. Phase 3 turns these into friend-list - /// state, in-app notifications, and the bell badge; for now they're logged so - /// the control plane is observable end-to-end. + /// Drain inbound control-plane messages and fold them into the friends list + /// and the session's met-peers, queueing any replies. Collected up front so + /// the presence borrow is released before we mutate `self` / re-borrow it to + /// send. (Phase 4 adds the bell badge + ShareCode handling.) fn pump_presence_events(&mut self) { - let Some(presence) = &self.presence else { + let Some(inbound) = self.presence.as_ref().map(|p| p.drain()) else { return; }; - for inbound in presence.drain() { - tracing::info!(from = %inbound.from, msg = ?inbound.msg, "presence: control message"); + if inbound.is_empty() { + return; } + let my_name = self.display_name.clone(); + let mut outbox: Vec<(iroh::EndpointId, ControlMsg)> = Vec::new(); + let mut store_changed = false; + + for inb in inbound { + let from = inb.from; + match inb.msg { + ControlMsg::Hello { name } => { + // A peer announcing themselves (the viewer→host intro, or the + // host's reply). Keep a known friend's name fresh; otherwise + // record them as a met peer and reply once on first contact. + if let Some(f) = self.friends.find_mut(&from) { + f.name = name; + store_changed = true; + } else if self.note_met(from, name) { + outbox.push((from, control_hello(&my_name))); + } + } + ControlMsg::FriendRequest { name } => { + self.note_met(from, name.clone()); + if self.friends.on_friend_request(from, name.clone()) { + // We'd already requested them — mutual, so it's settled. + outbox.push(( + from, + ControlMsg::FriendAccept { + name: my_name.clone(), + }, + )); + notify( + "PixelPass — now friends", + format!("You and {name} are now friends."), + ); + } else { + notify( + "PixelPass — friend request", + format!("{name} wants to be friends."), + ); + } + store_changed = true; + } + ControlMsg::FriendAccept { name } => { + if self.friends.on_friend_accept(from, name.clone()) { + store_changed = true; + notify( + "PixelPass — request accepted", + format!("{name} accepted your friend request."), + ); + } + } + ControlMsg::FriendDecline => { + if self.friends.remove(&from) { + store_changed = true; + } + } + ControlMsg::ShareCode { name, ticket } => { + // Phase 4 turns this into the bell badge + an in-app notice. + // For now, only honour codes from accepted friends and log. + if self.friends.is_accepted(&from) { + tracing::info!(from = %from, %name, "presence: friend shared a code: {ticket}"); + } else { + tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend"); + } + } + } + } + + if store_changed { + self.save_friends(); + } + if let Some(p) = &self.presence { + for (peer, msg) in outbox { + p.send(peer, msg); + } + } + } + + /// Record a peer met this session. Returns true if they were newly added + /// (false if we already knew them, in which case the name is refreshed). + fn note_met(&mut self, id: iroh::EndpointId, name: String) -> bool { + if let Some(m) = self.met.iter_mut().find(|m| m.id == id) { + // Don't overwrite a real name with a short-id placeholder. + if !name.is_empty() { + m.name = name; + } + false + } else { + self.met.push(MetPeer { id, name }); + true + } + } + + fn pending_incoming_count(&self) -> usize { + self.friends + .friends + .iter() + .filter(|f| f.state == FriendState::PendingIncoming) + .count() + } + + fn save_friends(&self) { + if let Err(e) = self.friends.save() { + tracing::warn!("failed to save friends list: {e:#}"); + } + } + + /// Send a friend request to a peer (or accept theirs if they already asked), + /// persisting the new state and notifying the peer over the control plane. + fn request_friend(&mut self, id: iroh::EndpointId, name: String) { + let my_name = self.display_name.clone(); + let msg = if matches!( + self.friends.find(&id).map(|f| f.state), + Some(FriendState::PendingIncoming) + ) { + self.friends.upsert(id, name, FriendState::Accepted); + ControlMsg::FriendAccept { name: my_name } + } else { + self.friends.upsert(id, name, FriendState::PendingOutgoing); + ControlMsg::FriendRequest { name: my_name } + }; + self.save_friends(); + if let Some(p) = &self.presence { + p.send(id, msg); + } + } + + /// Mark an incoming request accepted and tell the peer. + fn accept_friend(&mut self, id: iroh::EndpointId) { + let my_name = self.display_name.clone(); + if let Some(f) = self.friends.find_mut(&id) { + f.state = FriendState::Accepted; + self.save_friends(); + if let Some(p) = &self.presence { + p.send(id, ControlMsg::FriendAccept { name: my_name }); + } + } + } + + /// Remove a friend / decline a request / cancel an outgoing one, telling the + /// peer so their side drops us too. + fn remove_friend(&mut self, id: iroh::EndpointId) { + if self.friends.remove(&id) { + self.save_friends(); + if let Some(p) = &self.presence { + p.send(id, ControlMsg::FriendDecline); + } + } + } + + /// The "people you just connected with" offer shown on the running host / + /// viewer screens: met peers not yet in the friends list, each with an Add + /// button. + fn friend_offers(&mut self, ui: &mut egui::Ui) { + let offers: Vec<(iroh::EndpointId, String)> = self + .met + .iter() + .filter(|m| self.friends.find(&m.id).is_none()) + .map(|m| (m.id, m.name.clone())) + .collect(); + if offers.is_empty() { + return; + } + ui.add_space(12.0); + ui.separator(); + ui.label("People you just connected with:"); + let mut add: Option<(iroh::EndpointId, String)> = None; + for (id, name) in &offers { + ui.horizontal(|ui| { + ui.label(name.as_str()); + if ui.small_button("➕ Add friend").clicked() { + add = Some((*id, name.clone())); + } + }); + } + if let Some((id, name)) = add { + self.request_friend(id, name); + } + } + + fn friends_screen(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + if ui.button("← Menu").clicked() { + self.screen = Screen::Menu; + } + ui.heading("Friends"); + }); + ui.separator(); + + egui::ScrollArea::vertical().show(ui, |ui| { + // Your identity: editable display name + your stable id. + ui.horizontal(|ui| { + ui.label("Your name"); + if ui + .text_edit_singleline(&mut self.display_name) + .on_hover_text("Shown to friends in requests and shared codes.") + .changed() + { + persist_display_name(&self.display_name); + } + }); + if let Some(p) = &self.presence { + ui.label( + egui::RichText::new(format!("Your ID: {}", short_id(&p.id().to_string()))) + .small() + .weak(), + ); + } else { + ui.colored_label( + self.theme.active.warning, + "⚠ Friends service unavailable (no identity).", + ); + } + + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + + if self.friends.friends.is_empty() { + ui.label( + egui::RichText::new( + "No friends yet. Connect with someone, then use \"Add friend\" \ + on the host/view screen.", + ) + .weak(), + ); + return; + } + + // Collect the chosen action first so we don't mutate the store while + // iterating it. + enum Action { + Accept(iroh::EndpointId), + Remove(iroh::EndpointId), + } + let mut action: Option = None; + for f in &self.friends.friends { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&f.name).strong()); + ui.label( + egui::RichText::new(format!("· {}", short_id(&f.id.to_string()))) + .small() + .weak(), + ); + match f.state { + FriendState::Accepted => { + ui.label(egui::RichText::new("· friend").small().weak()); + if ui.small_button("Remove").clicked() { + action = Some(Action::Remove(f.id)); + } + } + FriendState::PendingIncoming => { + ui.label(egui::RichText::new("· wants to be friends").small().weak()); + if ui.small_button("Accept").clicked() { + action = Some(Action::Accept(f.id)); + } + if ui.small_button("Decline").clicked() { + action = Some(Action::Remove(f.id)); + } + } + FriendState::PendingOutgoing => { + ui.label(egui::RichText::new("· request sent").small().weak()); + if ui.small_button("Cancel").clicked() { + action = Some(Action::Remove(f.id)); + } + } + } + }); + } + match action { + Some(Action::Accept(id)) => self.accept_friend(id), + Some(Action::Remove(id)) => self.remove_friend(id), + None => {} + } + }); } /// Render the current screen. Called from inside the egui frame. @@ -1018,6 +1344,7 @@ impl PixelPassApp { Screen::Menu => self.menu(ui), Screen::Host => self.host(ui), Screen::Viewer => self.viewer(ui), + Screen::Friends => self.friends_screen(ui), Screen::Settings => self.settings(ui), Screen::Shortcuts => self.shortcuts(ui), } @@ -1051,7 +1378,9 @@ impl PixelPassApp { self.screen = Screen::Menu; } Screen::Settings if self.theme.editing => self.cancel_theme_edit(), - Screen::Settings | Screen::Shortcuts => self.screen = Screen::Menu, + Screen::Settings | Screen::Shortcuts | Screen::Friends => { + self.screen = Screen::Menu + } Screen::Menu => {} } return; @@ -1076,16 +1405,16 @@ impl PixelPassApp { Screen::Host => { if self.host.proc.is_some() { if key(Key::C) - && let Some(ticket) = self.host.ticket.clone() + && let Some(code) = self.host.share_code.clone() { - self.copy_to_clipboard(&ticket); + self.copy_to_clipboard(&code); } } else if key(Key::Space) || key(Key::Enter) { self.start_host(); } } // View's Enter (Connect) is handled in viewer_form. - Screen::Viewer | Screen::Settings | Screen::Shortcuts => {} + Screen::Viewer | Screen::Friends | Screen::Settings | Screen::Shortcuts => {} } } @@ -1179,6 +1508,16 @@ impl PixelPassApp { self.prefill_viewer_ticket(); } ui.add_space(20.0); + let pending = self.pending_incoming_count(); + let friends_label = if pending > 0 { + format!("👥 Friends ({pending})") + } else { + "👥 Friends".to_string() + }; + if ui.button(friends_label).clicked() { + self.screen = Screen::Friends; + } + ui.add_space(8.0); if ui.button("⚙ Settings").clicked() { // Refresh the picker so themes added to the folder since launch // (or last visit) show up without a restart. @@ -1554,9 +1893,9 @@ impl PixelPassApp { ui.add_space(12.0); - if let Some(ticket) = self.host.ticket.clone() { + if let Some(share_code) = self.host.share_code.clone() { ui.label("Share this code with your viewer(s):"); - if let Some(id) = ticket_endpoint_id(&ticket) { + if let Some(id) = self.host.ticket.as_deref().and_then(ticket_endpoint_id) { // The viewer shows "Connecting to …" with this same // truncation, so the two ends can be eyeballed for a match. ui.label( @@ -1568,7 +1907,7 @@ impl PixelPassApp { ui.add_space(4.0); egui::Frame::group(ui.style()).show(ui, |ui| { ui.add( - egui::Label::new(egui::RichText::new(&ticket).monospace().small()) + egui::Label::new(egui::RichText::new(&share_code).monospace().small()) .wrap() .selectable(true), ); @@ -1576,7 +1915,7 @@ impl PixelPassApp { ui.add_space(4.0); ui.horizontal(|ui| { if ui.button("📋 Copy code").clicked() { - self.copy_to_clipboard(&ticket); + self.copy_to_clipboard(&share_code); } if self.host.copied { ui.colored_label(self.theme.active.success, "✓ Copied to clipboard"); @@ -1593,13 +1932,13 @@ impl PixelPassApp { } // Lazy QR build: first draw after a Ticket event has `qr_texture = - // None`, so we encode the ticket and load the texture once. The + // None`, so we encode the share code and load the texture once. The // 4-module quiet zone (white border) matters — phone scanners reject // QR codes flush against a non-white edge. Skipped when the user // disabled the QR panel in Settings. if self.show_qr && self.host.qr_texture.is_none() - && let Ok(code) = qrcode::QrCode::new(ticket.as_bytes()) + && let Ok(code) = qrcode::QrCode::new(share_code.as_bytes()) { let w = code.width(); let quiet = 4; @@ -1633,6 +1972,8 @@ impl PixelPassApp { ui.colored_label(self.theme.active.warning, format!("⚠ {reason}")); } + self.friend_offers(ui); + ui.add_space(16.0); if ui .add_sized([140.0, 36.0], egui::Button::new("Stop hosting")) @@ -1646,6 +1987,7 @@ impl PixelPassApp { self.host.error = None; self.host.last_refusal = None; self.host.ticket = None; + self.host.share_code = None; self.host.info = None; self.host.active = 0; self.host.max = 0; @@ -1653,6 +1995,7 @@ impl PixelPassApp { self.host.copied = false; self.host.viewers.clear(); self.host.qr_texture = None; + self.met.clear(); let mut args = vec![ "--host".to_string(), @@ -1687,9 +2030,11 @@ impl PixelPassApp { self.host.proc = None; self.host.capturing = false; self.host.ticket = None; + self.host.share_code = None; self.host.copied = false; self.host.viewers.clear(); self.host.qr_texture = None; + self.met.clear(); } /// Drain the host child's event channel into state, and detect an @@ -1723,13 +2068,21 @@ impl PixelPassApp { fn apply_host_event(&mut self, ev: ChildEvent) { match ev { ChildEvent::Ticket { value } => { + // Wrap the bare ticket with our stable control id so a viewer + // learns who to befriend (see code::wrap). Falls back to the + // bare ticket if the presence service isn't up. + let share_code = match &self.presence { + Some(p) => code::wrap(p.id(), &value), + None => value.clone(), + }; // Auto-copy on arrival, mirroring the CLI/interactive host // (which copies the ticket and prints "copied to your - // clipboard"). A failure here is non-fatal: the ticket stays + // clipboard"). A failure here is non-fatal: the code stays // visible for manual copy, and `copied` stays false so the UI // doesn't falsely claim success. - self.host.copied = set_clipboard(&value); + self.host.copied = set_clipboard(&share_code); self.host.ticket = Some(value); + self.host.share_code = Some(share_code); } ChildEvent::HostInfo { display_server, @@ -1855,9 +2208,11 @@ impl PixelPassApp { // Decode the pasted code live: confirms it's a real ticket and shows // which host it points at, so a stale clipboard paste is caught here - // instead of after the 15s connect timeout. + // instead of after the 15s connect timeout. Unwrap first so a wrapped + // friend code decodes to its underlying ticket. let trimmed = self.viewer.ticket_input.trim().to_string(); - let decoded_id = ticket_endpoint_id(&trimmed); + let (host_ctrl, bare_ticket) = code::unwrap(&trimmed); + let decoded_id = ticket_endpoint_id(&bare_ticket); if !trimmed.is_empty() { ui.add_space(4.0); match &decoded_id { @@ -1870,6 +2225,13 @@ impl PixelPassApp { "⚠ This doesn't look like a share code.", ), }; + if host_ctrl.is_some() { + ui.label( + egui::RichText::new("This host can be added as a friend after you connect.") + .small() + .weak(), + ); + } } ui.add_space(10.0); @@ -1915,6 +2277,8 @@ impl PixelPassApp { ui.colored_label(self.theme.active.waiting, msg); } + self.friend_offers(ui); + ui.add_space(16.0); if ui .add_sized([140.0, 36.0], egui::Button::new("Disconnect")) @@ -1946,7 +2310,11 @@ impl PixelPassApp { self.viewer.url = None; self.viewer.launched = false; - let ticket = self.viewer.ticket_input.trim().to_string(); + // Unwrap a wrapped friend code into (host control id, bare ticket). The + // child only ever sees the bare ticket; the control id is kept so we can + // announce ourselves to the host once connected. + let (host_ctrl, ticket) = code::unwrap(&self.viewer.ticket_input); + self.viewer.host_control_id = host_ctrl; self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id)); let mut args = vec![ticket, "--output".to_string(), "json".to_string()]; if let Some(relay) = &self.relay { @@ -1964,6 +2332,8 @@ impl PixelPassApp { self.viewer.url = None; self.viewer.launched = false; self.viewer.connecting_to = None; + self.viewer.host_control_id = None; + self.met.clear(); } fn pump_viewer_events(&mut self) { @@ -1980,6 +2350,16 @@ impl PixelPassApp { Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")), } } + // If this was a wrapped friend code, announce ourselves to the + // host over the control plane so both ends can offer to befriend + // each other. We record the host as a met peer up front (name + // filled in when the host's Hello reply arrives). + if let Some(host_id) = self.viewer.host_control_id { + self.note_met(host_id, short_id(&host_id.to_string())); + if let Some(p) = &self.presence { + p.send(host_id, control_hello(&self.display_name)); + } + } } } diff --git a/src/gui/presence.rs b/src/gui/presence.rs index a617150..173bd7c 100644 --- a/src/gui/presence.rs +++ b/src/gui/presence.rs @@ -18,24 +18,50 @@ use tokio::sync::mpsc as tmpsc; use super::Waker; use crate::common::{ - control::{self, Inbound}, + control::{self, ControlMsg, Inbound}, endpoint, identity, }; +/// An outbound control message the UI asks the service to deliver. +struct Outbound { + peer: EndpointId, + msg: ControlMsg, +} + /// Handle the GUI holds for the presence service. Dropping it doesn't stop the /// service (the thread is detached; the endpoint closes when the process exits) /// — it just stops the UI from draining inbound messages. pub struct PresenceHandle { + /// Our stable control-plane id — what friends know us by, and what we embed + /// in a wrapped share code so a viewer can find us. + id: EndpointId, /// Inbound control messages, drained by [`PresenceHandle::drain`] each tick. rx: Receiver, + /// Outbound requests handed to the service thread. Unbounded tokio sender so + /// the sync UI can enqueue without blocking or being inside the runtime. + out_tx: tmpsc::UnboundedSender, } impl PresenceHandle { + /// Our stable control-plane id. + pub fn id(&self) -> EndpointId { + self.id + } + /// Pull every control message received since the last call. Collected by the /// caller so it can take `&mut self` while handling them. pub fn drain(&self) -> Vec { std::iter::from_fn(|| self.rx.try_recv().ok()).collect() } + + /// Enqueue a message for delivery to `peer`. Fire-and-forget from the UI's + /// view; the service connects, delivers, and logs a failure (Phase 4 adds a + /// retry queue). A send error here only means the service thread is gone. + pub fn send(&self, peer: EndpointId, msg: ControlMsg) { + if self.out_tx.send(Outbound { peer, msg }).is_err() { + tracing::warn!("presence: service thread gone; dropping outbound message"); + } + } } /// Start the presence service. Returns `None` if the persistent identity can't @@ -54,18 +80,26 @@ pub fn start(waker: Waker, relay: Option) -> Option { tracing::info!(%id, "presence: starting control service"); let (tx, rx) = mpsc::channel::(); + let (out_tx, out_rx) = tmpsc::unbounded_channel::(); thread::Builder::new() .name("pixelpass-presence".into()) - .spawn(move || run(relay, id, tx, waker)) + .spawn(move || run(relay, id, tx, out_rx, waker)) .map_err(|e| tracing::warn!("presence: could not spawn service thread: {e}")) .ok()?; - Some(PresenceHandle { rx }) + Some(PresenceHandle { id, rx, out_tx }) } /// Thread body: a current-thread tokio runtime that binds the control endpoint, -/// runs the accept loop, and bridges inbound messages to the UI channel. -fn run(relay: Option, id: EndpointId, tx: mpsc::Sender, waker: Waker) { +/// runs the accept loop, bridges inbound messages to the UI channel, and +/// delivers outbound messages the UI enqueues. +fn run( + relay: Option, + id: EndpointId, + tx: mpsc::Sender, + mut out_rx: tmpsc::UnboundedReceiver, + waker: Waker, +) { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -99,7 +133,22 @@ fn run(relay: Option, id: EndpointId, tx: mpsc::Sender, waker: } }); + // Deliver outbound messages the UI enqueues, each on its own task so a + // slow/offline peer doesn't hold up the others. + let send_ep = ep.clone(); + let sender = tokio::spawn(async move { + while let Some(out) = out_rx.recv().await { + let ep = send_ep.clone(); + tokio::spawn(async move { + if let Err(e) = control::send(&ep, out.peer, &out.msg).await { + tracing::warn!(peer = %out.peer, "presence: outbound send failed: {e:#}"); + } + }); + } + }); + control::serve(ep, itx).await; forward.abort(); + sender.abort(); }); }