Build the friends feature on top of the phase-2 control plane: you can now befriend someone you've connected with and manage a contacts list. - common/friends.rs: a persisted FriendStore in its own friends.toml (kept out of config.toml so a headless --reconfigure can't clobber it, same as identity.key). Friends are keyed by stable control EndpointId; state is PendingOutgoing / PendingIncoming / Accepted. The handshake transitions (on_friend_request → mutual-match detection, on_friend_ accept) are pure and unit-tested. - gui/code.rs: the bootstrap. The GUI host wraps its share code as `pixelpassF1:<control-id>.<ticket>` so a viewer learns the host's stable id; unwrap is lenient, so a bare/CLI ticket still works (no friend offer). The video/streaming path is untouched. - presence service gains an outbound path (unbounded channel → per-msg send tasks) and exposes our control id for wrapping codes. - gui wiring: on connect, the viewer announces itself to the host with a Hello (carrying our display name); the host replies once, so both ends learn each other and an "Add friend" offer appears on the running host/view screens. Incoming requests/accepts/declines fold into the store with desktop notifications. New Friends screen (accept/decline/ remove, edit your display name, see your id) reachable from the menu, which shows a pending-request count. New [gui] display_name setting, seeded from $USER. Verified: friends store + handshake transitions covered by unit tests (7); code wrap/unwrap round-trips (4); the control loopback still passes; the live GUI starts clean with the presence endpoint online. fmt + clippy clean on both features; 41 gui + 8 headless tests pass. The full two-party UX (connect → mutual add → persisted) wants a cross-machine manual check, as usual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
3.2 KiB
Rust
89 lines
3.2 KiB
Rust
//! Share-code wrapping: carrying the host's stable friend id alongside the
|
|
//! one-shot video ticket.
|
|
//!
|
|
//! A bare video ticket identifies only the host's *ephemeral* video endpoint,
|
|
//! so two people who meet over one can't learn each other's stable friend id —
|
|
//! the thing the friends system needs. The GUI host therefore wraps its ticket
|
|
//! with its control-plane [`EndpointId`]; the viewer unwraps it, dials the
|
|
//! video ticket as before, and now also knows who to befriend (and announces
|
|
//! itself back over the control plane so the host learns the viewer in turn).
|
|
//!
|
|
//! Format: `pixelpassF1:<host-control-id>.<bare-ticket>`. Both the id and the
|
|
//! ticket are base32 text with no `.`, so a single `.` separator is
|
|
//! unambiguous. [`unwrap`] is lenient: anything without the prefix is treated
|
|
//! as a bare ticket, so a plain CLI ticket pasted into the GUI still works (it
|
|
//! just offers no friend option). The host name isn't carried here — the
|
|
//! viewer's announcement triggers a name exchange over the control plane.
|
|
|
|
use std::str::FromStr;
|
|
|
|
use iroh::EndpointId;
|
|
|
|
/// Prefix marking a wrapped friend code. The `F1` is the wrap-format version,
|
|
/// bumped if the layout ever changes.
|
|
const MAGIC: &str = "pixelpassF1:";
|
|
|
|
/// Wrap a bare ticket with the host's control id, for display/copy/QR.
|
|
pub fn wrap(host_id: EndpointId, ticket: &str) -> String {
|
|
format!("{MAGIC}{host_id}.{ticket}")
|
|
}
|
|
|
|
/// Split an input into `(host control id if it was a wrapped code, bare
|
|
/// ticket)`. A bare or unrecognised input yields `(None, trimmed input)` so the
|
|
/// viewer path stays identical to before for plain tickets.
|
|
pub fn unwrap(code: &str) -> (Option<EndpointId>, String) {
|
|
let code = code.trim();
|
|
if let Some(rest) = code.strip_prefix(MAGIC)
|
|
&& let Some((id_str, ticket)) = rest.split_once('.')
|
|
&& let Ok(id) = EndpointId::from_str(id_str)
|
|
&& !ticket.is_empty()
|
|
{
|
|
return (Some(id), ticket.to_string());
|
|
}
|
|
(None, code.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_id() -> EndpointId {
|
|
iroh::SecretKey::generate().public()
|
|
}
|
|
|
|
#[test]
|
|
fn wrap_unwrap_round_trips() {
|
|
let id = sample_id();
|
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
|
let code = wrap(id, ticket);
|
|
let (got_id, got_ticket) = unwrap(&code);
|
|
assert_eq!(got_id, Some(id));
|
|
assert_eq!(got_ticket, ticket);
|
|
}
|
|
|
|
#[test]
|
|
fn bare_ticket_passes_through() {
|
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
|
let (id, got) = unwrap(ticket);
|
|
assert_eq!(id, None);
|
|
assert_eq!(got, ticket);
|
|
}
|
|
|
|
#[test]
|
|
fn trims_surrounding_whitespace() {
|
|
let ticket = "endpointaabwxjex";
|
|
let (id, got) = unwrap(&format!(" {} ", wrap(sample_id(), ticket)));
|
|
assert!(id.is_some());
|
|
assert_eq!(got, ticket);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_wrapped_code_falls_back_to_bare() {
|
|
// Prefix present but the id isn't a valid EndpointId → treat the whole
|
|
// thing as a (doomed) bare ticket rather than panicking.
|
|
let (id, got) = unwrap("pixelpassF1:not-an-id.endpointaa");
|
|
assert_eq!(id, None);
|
|
assert_eq!(got, "pixelpassF1:not-an-id.endpointaa");
|
|
}
|
|
}
|