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:
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user