feat(friends): always-on control plane for the presence service (phase 2)

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>
This commit is contained in:
2026-05-30 16:25:45 -04:00
parent 14fc1af716
commit f5d0333366
7 changed files with 460 additions and 23 deletions
+23
View File
@@ -37,6 +37,7 @@
//! dropped and no egui frame is running.
mod child;
mod presence;
mod theme;
mod tray;
@@ -65,6 +66,7 @@ use winit::raw_window_handle::HasWindowHandle as _;
use winit::window::{Window, WindowAttributes, WindowId};
use self::child::{ChildEvent, ChildProc};
use self::presence::PresenceHandle;
use self::tray::{TrayAction, TrayHandle, TrayStatus};
/// Initial / minimum window size, in logical points. Initial height fits the
@@ -601,6 +603,9 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
};
// The tray runs on its own thread and wakes us via the proxy.
let tray = tray::start(proxy.clone());
// The friends presence service runs on its own thread too, waking us when
// a control message arrives.
let presence = presence::start(waker.clone(), relay.clone());
let gui_settings = crate::common::config::load()
.map(|c| c.gui)
.unwrap_or_default();
@@ -626,6 +631,7 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
status: None,
},
waker,
presence,
};
let mut app = App {
state,
@@ -880,6 +886,10 @@ struct PixelPassApp {
theme: ThemeState,
/// Wakes the winit loop when a spawned child emits/exits.
waker: Waker,
/// The always-on friends presence service (control-plane endpoint). `None`
/// if it couldn't start (no identity), in which case friends features are
/// simply absent.
presence: Option<PresenceHandle>,
}
/// The active theme plus the Settings picker/editor working state.
@@ -956,9 +966,22 @@ impl PixelPassApp {
fn tick(&mut self) {
self.pump_host_events();
self.pump_viewer_events();
self.pump_presence_events();
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.
fn pump_presence_events(&mut self) {
let Some(presence) = &self.presence else {
return;
};
for inbound in presence.drain() {
tracing::info!(from = %inbound.from, msg = ?inbound.msg, "presence: control message");
}
}
/// Render the current screen. Called from inside the egui frame.
fn draw(&mut self, ui: &mut egui::Ui) {
self.handle_keys(ui);