feat(presence): live friends listener + ownership move (W7 B2)

Makes the friends list live, building on the B1 persistent endpoint.

Friends ownership moves into core (was the GUI's):
- Core loads/owns friends.json behind a shared Mutex<FriendStore>; a malformed
  load yields an empty store flagged READ-ONLY so we never overwrite the damaged
  file (fixes backlog A16). New commands AddFriend/RemoveFriend/RenameFriend +
  UiEvent::FriendsUpdated{friends,read_only}; the GUI is now a read-only mirror
  that renders from the event and drives mutations via commands. The friends UI
  shows a warning + blocks edits when read-only.
- Presence posture pushed to core via SetPresenceMode (persistence stays in
  AppConfig); held in a shared Mutex for the listener/scheduler.

Live listener + outbound scheduler:
- New FriendsProtocol ProtocolHandler on the persistent Router for FRIENDS_ALPN
  (the router owns accept(), so the listener can't be presence_net::serve — same
  delegation pattern as B1's AudioRouter). Its reply policy reads the shared
  friends/mode/current-room and uses presence::should_answer: answer friends only,
  never while invisible, and report our current gathering's restamped member
  ticket so a friend can one-click Join. handle()'s body is factored into a shared
  exchange() used by both serve (tests) and FriendsProtocol.
- Outbound ping scheduler folded into the core loop via tokio::select! on a slow
  interval (60s, first pass delayed 3s for endpoint online). FULLY DARK while
  Invisible (no probing at all — user's choice). Each pass runs detached so it
  never blocks command handling and picks up a rebuilt stack next tick; probes
  friends with a saved addr in parallel and emits UiEvent::FriendPresence.
- note_seen auto-heal: a connected peer who is a friend has their last_addr
  refreshed (+persisted) so the scheduler can reach them later.
- current_room shared state set on Join (restamped ticket) / cleared on Leave.

P5 UI: each friend shows online / offline / in-room with a one-click Join.

256 lib + 6 reconnect + 4 loopback + 2 ignored real-endpoint tests green, clippy
--all-targets clean, release builds. B2a (ownership/A16) is solo-verifiable; the
live listener + scheduler need the 2-machine field test before this merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:51:45 -04:00
co-authored by Claude Opus 4.8
parent af482d9666
commit 1ed64cbede
4 changed files with 407 additions and 39 deletions
+49
View File
@@ -101,6 +101,13 @@ pub async fn serve(endpoint: Endpoint, handler: Handler) {
async fn handle(incoming: Incoming, handler: Handler) -> Result<()> {
let conn = incoming.await.context("inbound connection failed")?;
exchange(&conn, &handler).await
}
/// One ping→pong exchange on an already-accepted connection: read the ping,
/// ask the policy, reply (or reveal nothing), close. Shared by the standalone
/// [`serve`] loop and the [`FriendsProtocol`] router handler.
async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> {
// The authenticated remote id — NOT anything the peer puts in the payload.
let from = conn.remote_id();
@@ -129,6 +136,48 @@ async fn handle(incoming: Incoming, handler: Handler) -> Result<()> {
Ok(())
}
/// The live friends-presence listener as an iroh [`ProtocolHandler`], registered
/// once on the app's single persistent `Router` for [`FRIENDS_ALPN`]. Because the
/// Router owns `endpoint.accept()`, the listener can't be the standalone [`serve`]
/// loop (that would compete for accepts); this delegates each inbound connection to
/// the same [`exchange`] body, with the reply policy injected as a [`Handler`]
/// (which wraps [`crate::presence::should_answer`] + builds the Pong). Mirrors the
/// `AudioRouter` pattern from the B1 persistent-endpoint refactor.
#[derive(Clone)]
pub struct FriendsProtocol {
handler: Handler,
}
impl FriendsProtocol {
pub fn new(handler: Handler) -> Self {
Self { handler }
}
}
impl std::fmt::Debug for FriendsProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FriendsProtocol").finish_non_exhaustive()
}
}
impl iroh::protocol::ProtocolHandler for FriendsProtocol {
fn accept(
&self,
connection: iroh::endpoint::Connection,
) -> impl std::future::Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
let handler = self.handler.clone();
async move {
// A failed exchange (malformed ping, timeout, etc.) is logged, not
// surfaced as an accept error — one bad prober shouldn't disturb the
// listener. Returning Ok keeps the router loop healthy.
if let Err(e) = exchange(&connection, &handler).await {
crate::log_msg(&format!("presence: inbound friends exchange failed: {e:#}"));
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;