Bank the W7 investigation as a scope contract. Key finding: pixelpass's friends/control/identity code ports near-verbatim (same iroh 1.0.0-rc.0), but PeerSpeak's endpoint is room-scoped (built in Join, torn down on Leave) with a fresh identity each launch — so the real work is a new always-on control-plane endpoint + persistent identity, not the friends list. Phased plan (0 identity / 1 control plane / 2 store+handshake / 3 drawer UI / 4 security+field-test), ~4 sessions, with 4 open decisions that block build (discovery-vs-privacy being the big one). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.8 KiB
Contacts list + room-invite notifications (W7) — plan / scope contract
Status: SCOPED, not started (assessment 2026-06-14 by the senior). This is the
scope contract; update it as phases land. Source: wishlist item W7 in
~/Documents/handoff-docs/Gemini/peerspeak/wishlist-handoff.md.
Goal
Save known peers and make joining their rooms frictionless:
- A contacts list — save known peers (by stable id + name) so you don't re-exchange tickets every time.
- A notification drawer where incoming room invites appear out-of-room.
- Each invite carries the room ticket in a button; clicking it auto-fills the join-room text box (one-click join — never copy/paste a ticket).
The decisive architectural finding (read this first)
The wishlist says "port pixelpass's friends-list/notification system." The
protocol code ports nearly verbatim — both projects are on the exact same
iroh = "1.0.0-rc.0" — but the integration is genuinely new architecture for
PeerSpeak, because the two apps have opposite networking lifecycles:
- pixelpass runs a persistent identity + an always-on control-plane endpoint (bound at GUI start, online the whole time the app is open), separate from any video session. The friends system rides that.
- PeerSpeak has no endpoint at all when not in a call:
core/mod.rs:500(theJoinhandler) builds the endpoint and tears it down onLeave, andcore/mod.rs:421mints a fresh randomSecretKey::generate()every launch, so a peer'sEndpointIdchanges each run and nothing is listening while idle.
W7 fundamentally needs to reach a contact who is not in a room — which PeerSpeak currently cannot do at all. That gap, not the friends list, is the real work. Porting the proven protocol/store is ~60% of the effort (low risk); the new 40% is the always-on control plane (Phase 1) and the all-new drawer/ contacts UI (Phase 3).
Reusable prior art (pixelpass — ~/git/butter/pixelpass/)
Field-verified + merged (04bc0a8 + audit 6d0bf99/cfc4800). Same stack, same
iroh version → API-compatible.
| pixelpass file | lines | reuse for PeerSpeak |
|---|---|---|
src/common/identity.rs |
~140 | near-verbatim — persistent ed25519 key, load_or_create(), atomic 0600 write. |
src/common/control.rs |
~260 | protocol reusable as-is — one-message-per-connection JSON over a bi-stream, authenticated sender via conn.remote_id() (not spoofable), one-byte ACK = delivery+parse signal, serve() accept loop → mpsc::Receiver<Inbound>. ControlMsg variants Hello/FriendRequest/FriendAccept/FriendDecline/ShareCode. |
src/common/friends.rs |
~330 | near-verbatim — FriendStore, mutual-consent FriendState (PendingOutgoing/PendingIncoming/Accepted), atomic write, keyed by stable EndpointId. |
src/common/alpn.rs |
— | CONTROL_ALPN = b"pixelpass/ctrl/0" pattern → mint b"peerspeak/ctrl/0". |
src/gui/presence.rs |
~299 | reference, not copy — service orchestration + online/presence indicators; PeerSpeak's iced UI differs, so adapt. |
Locked design decisions
(none yet — see "Open decisions" below; the user must answer the four before build)
Adaptations from pixelpass's model
- pixelpass's
ControlMsg::ShareCode { name, ticket }(push a video share-code to a friend) maps directly onto PeerSpeak's need: a room invite carrying aPeerSpeakTicket(src/network/mod.rs:81). Likely rename toRoomInvite. - Store contacts/identity as JSON (PeerSpeak already uses
serde_jsoneverywhere; pixelpass usestoml) to avoid adding thetomldependency — see Open decision #3.
Phases
Phase 0 — Persistent identity · Small (~1 session)
Port identity.rs → ~/.config/peerspeak/identity.key (0600). Swap
core/mod.rs:421 SecretKey::generate() for load_or_create(). Unit-testable
(hex round-trip tests come with it).
- Side effect: the gossip-signing key (security S2) becomes stable across launches → minor cross-room linkability. Note it (Open decision #2).
Phase 1 — Always-on control plane · Large (the crux, ~1–2 sessions)
Stand up a second, long-lived endpoint on peerspeak/ctrl/0, online whenever
the app runs, owned by the core loop outside the Join/Leave session
lifecycle (today all networking lives inside ActiveSession).
- Port
control.rs(protocol as-is). Wire its inboundmpsc::Receiver<Inbound>into the core→UI event flow (newUiEventvariants +CoreCommands for send-request / accept / decline / invite). - Discovery: the control endpoint likely needs
presets::N0(n0 DNS discovery) to be reachable by bare id while idle, even if the call posture staysRelayNoDiscovery. This is Open decision #1. - This is net-new long-lived networking; the bulk of W7's risk lives here.
Phase 2 — Contacts store + friend handshake · Medium (~1 session)
Port friends.rs (as JSON). Wire FriendRequest/Accept/Decline through the
control plane and core. Persist ~/.config/peerspeak/contacts.json. Pure store =
unit-testable. Implement the RoomInvite send/receive path (carry a
PeerSpeakTicket).
Phase 3 — UI: contacts list + notification drawer + one-click join · Medium (~1 session)
All-new iced UI (PeerSpeak has no drawer/notification panel today — the only "drawer" is the chat layout):
- Contacts view — add by id, see online/pending status, accept/decline.
- Notification drawer — top-right popup, reuse the layout-switcher popup
pattern (
SelectRoomLayoutflow) in the always-visible top-right cluster; incoming invites listed with a one-click-join button. - One-click join — the button routes the carried ticket into the join-room text-input state (validate, then pre-fill; do not auto-join).
- Online/presence indicators driven by
ControlMsg::Hellorefreshes. - Cross-ref W4 avatars: show a contact's avatar (ties into
PeerState.avatar).
Phase 4 — Security review + 2-machine field test · Small–Medium
New untrusted surface:
- Unsolicited control messages from arbitrary ids = friend-request spam / DoS vector → need a cap / rate-limit (Open decision #4).
- Invite-carried tickets are untrusted even from a contact → validate defensively before pre-fill; never auto-join.
- Sender identity is authenticated (
remote_id()) — inherited from pixelpass's design, good. cargo audit(no new deps expected if we store as JSON, not TOML).- 2-machine field test on dopedart: add-contact both ways, request/accept, send a room invite out-of-room, one-click join.
Open decisions (the user must answer before build)
- Discovery vs privacy (the big one). Reaching an idle contact by stable id
needs n0 DNS discovery, which conflicts with the privacy-minded
RelayNoDiscoverydefault (user-security-preferences / telemetry stance). Proposed: run only the control plane on n0 discovery; keep the user's chosen posture for the call itself. Accept? - Stable identity → stable gossip-signing key across rooms (minor linkability). Acceptable?
- Dependency: store contacts/identity as JSON (no new dep) rather than pixelpass's TOML. Confirm (default: JSON).
- Spam control: cap / rate-limit unsolicited friend requests from the start?
Effort summary
Medium–High, ~4 focused sessions. ~60% proven low-risk port (same iroh version); ~40% new design — the always-on control endpoint (Phase 1) and the all-new drawer/contacts UI (Phase 3).
Cross-references
- Wishlist W7 (the request, with pixelpass-port pointer):
wishlist-handoff.md. - Notification system precedent (chimes):
src/notify.rs+ W6 (per-sound toggles,7e75ae3). - Top-right popup pattern to reuse: the layout switcher (
SelectRoomLayout). - Ticket type to carry in invites:
PeerSpeakTicket(src/network/mod.rs:81).