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:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The always-on friends presence service.
|
||||
//!
|
||||
//! A control-plane iroh endpoint ([`endpoint::bind_control`]) that lives for the
|
||||
//! whole GUI session on its own thread with a current-thread tokio runtime — the
|
||||
//! GUI is a synchronous winit/egui loop, so iroh's async work can't run on it
|
||||
//! (the same reason [`super::tray`] has its own thread + runtime).
|
||||
//!
|
||||
//! Inbound control messages are forwarded over a std mpsc channel the UI drains
|
||||
//! each [`super::PixelPassApp::tick`]; the [`Waker`] is pinged on arrival so a
|
||||
//! message wakes the loop even while the window is hidden to the tray — the same
|
||||
//! trick the headless-child reader uses.
|
||||
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::thread;
|
||||
|
||||
use iroh::EndpointId;
|
||||
use tokio::sync::mpsc as tmpsc;
|
||||
|
||||
use super::Waker;
|
||||
use crate::common::{
|
||||
control::{self, Inbound},
|
||||
endpoint, identity,
|
||||
};
|
||||
|
||||
/// 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 {
|
||||
/// Inbound control messages, drained by [`PresenceHandle::drain`] each tick.
|
||||
rx: Receiver<Inbound>,
|
||||
}
|
||||
|
||||
impl PresenceHandle {
|
||||
/// 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<Inbound> {
|
||||
std::iter::from_fn(|| self.rx.try_recv().ok()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the presence service. Returns `None` if the persistent identity can't
|
||||
/// be loaded — the GUI then simply runs without friends features rather than
|
||||
/// refusing to start. The endpoint binds asynchronously on the spawned thread;
|
||||
/// our id is known immediately because it derives from the saved key, so we can
|
||||
/// fail-fast and log it without waiting on the relay handshake.
|
||||
pub fn start(waker: Waker, relay: Option<String>) -> Option<PresenceHandle> {
|
||||
let id: EndpointId = match identity::load_or_create() {
|
||||
Ok(key) => key.public(),
|
||||
Err(e) => {
|
||||
tracing::warn!("presence: no identity, friends features disabled: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
tracing::info!(%id, "presence: starting control service");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Inbound>();
|
||||
thread::Builder::new()
|
||||
.name("pixelpass-presence".into())
|
||||
.spawn(move || run(relay, id, tx, waker))
|
||||
.map_err(|e| tracing::warn!("presence: could not spawn service thread: {e}"))
|
||||
.ok()?;
|
||||
|
||||
Some(PresenceHandle { rx })
|
||||
}
|
||||
|
||||
/// 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<String>, id: EndpointId, tx: mpsc::Sender<Inbound>, waker: Waker) {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!("presence: failed to build runtime: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async move {
|
||||
let ep = match endpoint::bind_control(relay.as_deref()).await {
|
||||
Ok(ep) => ep,
|
||||
Err(e) => {
|
||||
tracing::error!("presence: failed to bind control endpoint: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(%id, "presence: control endpoint online");
|
||||
|
||||
// Bridge the async accept loop to the sync UI channel, waking the loop
|
||||
// on each message so it lands even while hidden to the tray.
|
||||
let (itx, mut irx) = tmpsc::channel::<Inbound>(32);
|
||||
let forward = tokio::spawn(async move {
|
||||
while let Some(inbound) = irx.recv().await {
|
||||
if tx.send(inbound).is_err() {
|
||||
break; // UI gone
|
||||
}
|
||||
waker.wake();
|
||||
}
|
||||
});
|
||||
|
||||
control::serve(ep, itx).await;
|
||||
forward.abort();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user