feat(friends): friend store, mutual handshake, and Friends UI (phase 3)
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>
This commit is contained in:
@@ -36,6 +36,10 @@ pub struct GuiSettings {
|
||||
/// `~/.config/pixelpass/themes/`). Defaults to the built-in Default Dark.
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String,
|
||||
/// The display name shown to friends (in requests and shared codes).
|
||||
/// Seeded from the login name; editable in Settings.
|
||||
#[serde(default = "default_display_name")]
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl Default for GuiSettings {
|
||||
@@ -44,6 +48,7 @@ impl Default for GuiSettings {
|
||||
close_to_tray: false,
|
||||
show_qr: true,
|
||||
theme: default_theme(),
|
||||
display_name: default_display_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +61,15 @@ fn default_theme() -> String {
|
||||
"Default Dark".to_string()
|
||||
}
|
||||
|
||||
/// Seed the friends display name from the login name, falling back to a
|
||||
/// generic label when `$USER` isn't set.
|
||||
fn default_display_name() -> String {
|
||||
std::env::var("USER")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "PixelPass user".to_string())
|
||||
}
|
||||
|
||||
/// Result of the first-run upstream measurement.
|
||||
///
|
||||
/// `status = "unmeasured"` means we've never asked the user — show the
|
||||
|
||||
@@ -82,10 +82,6 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
||||
/// `peer` is usually a bare [`EndpointId`] — friends store only the stable id,
|
||||
/// and n0 DNS discovery resolves it to a live address. The full [`EndpointAddr`]
|
||||
/// form exists for callers that already hold one (and for hermetic tests).
|
||||
//
|
||||
// Lands ahead of its caller: the outbound paths (friend requests, code pushes)
|
||||
// are wired into the GUI in Phase 3/4. The loopback test exercises it now.
|
||||
#[allow(dead_code)]
|
||||
pub async fn send(
|
||||
endpoint: &Endpoint,
|
||||
peer: impl Into<EndpointAddr>,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Persistent friends store at `~/.config/pixelpass/friends.toml`.
|
||||
//!
|
||||
//! Kept in its own file rather than a `[friends]` section of `config.toml` so
|
||||
//! the headless CLI — which never manages friends and would round-trip the
|
||||
//! config without this knowledge — can't drop the list on a `--reconfigure`.
|
||||
//! Same reasoning as the separate `identity.key`.
|
||||
//!
|
||||
//! A friend is identified by their stable control-plane [`EndpointId`] (the id
|
||||
//! from [`super::endpoint::bind_control`]). `EndpointId` serialises as its
|
||||
//! string form in TOML, so the file is human-readable and hand-editable.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Where a friendship sits in the mutual-consent handshake.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FriendState {
|
||||
/// We've sent them a request and are waiting for them to accept.
|
||||
PendingOutgoing,
|
||||
/// They've requested us; waiting for the local user to accept or decline.
|
||||
PendingIncoming,
|
||||
/// Both sides have agreed — a real friend.
|
||||
Accepted,
|
||||
}
|
||||
|
||||
/// One entry in the friends list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Friend {
|
||||
pub id: EndpointId,
|
||||
/// Display name — seeded from the name the peer reported, locally editable.
|
||||
pub name: String,
|
||||
pub state: FriendState,
|
||||
}
|
||||
|
||||
/// The persisted friends list. Serialises as a TOML array of tables
|
||||
/// (`[[friends]]`).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FriendStore {
|
||||
#[serde(default)]
|
||||
pub friends: Vec<Friend>,
|
||||
}
|
||||
|
||||
/// Returns `~/.config/pixelpass/friends.toml`. Shares the config directory with
|
||||
/// [`super::config`]; the parent is created on save.
|
||||
pub fn friends_path() -> Result<PathBuf> {
|
||||
Ok(super::config::config_path()?
|
||||
.parent()
|
||||
.context("config path has no parent directory")?
|
||||
.join("friends.toml"))
|
||||
}
|
||||
|
||||
/// Load the store, or a default (empty) one if the file doesn't exist yet.
|
||||
/// Parse errors bubble up so a hand-edit being debugged isn't silently
|
||||
/// overwritten.
|
||||
pub fn load() -> Result<FriendStore> {
|
||||
let path = friends_path()?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => toml::from_str(&s).with_context(|| format!("failed to parse {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
impl FriendStore {
|
||||
/// Atomic write via tempfile-in-same-dir + rename (mirrors
|
||||
/// [`super::config::save`]).
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = friends_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("friends path has no parent directory")?;
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let serialized = toml::to_string_pretty(self).context("failed to serialize friends")?;
|
||||
let tmp = parent.join(format!(".friends.toml.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
f.write_all(serialized.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find(&self, id: &EndpointId) -> Option<&Friend> {
|
||||
self.friends.iter().find(|f| &f.id == id)
|
||||
}
|
||||
|
||||
pub fn find_mut(&mut self, id: &EndpointId) -> Option<&mut Friend> {
|
||||
self.friends.iter_mut().find(|f| &f.id == id)
|
||||
}
|
||||
|
||||
/// True iff this id is a fully-accepted friend — the gate the code-push
|
||||
/// (Phase 4) and "is this a known friend?" checks use.
|
||||
pub fn is_accepted(&self, id: &EndpointId) -> bool {
|
||||
matches!(
|
||||
self.find(id),
|
||||
Some(Friend {
|
||||
state: FriendState::Accepted,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/// Insert a new friend, or update an existing one's `name`/`state` in place.
|
||||
/// Returns a mutable reference to the stored entry.
|
||||
pub fn upsert(&mut self, id: EndpointId, name: String, state: FriendState) -> &mut Friend {
|
||||
if let Some(idx) = self.friends.iter().position(|f| f.id == id) {
|
||||
let f = &mut self.friends[idx];
|
||||
f.name = name;
|
||||
f.state = state;
|
||||
f
|
||||
} else {
|
||||
self.friends.push(Friend { id, name, state });
|
||||
self.friends.last_mut().expect("just pushed")
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a friend by id. Returns whether an entry was removed.
|
||||
pub fn remove(&mut self, id: &EndpointId) -> bool {
|
||||
let before = self.friends.len();
|
||||
self.friends.retain(|f| &f.id != id);
|
||||
self.friends.len() != before
|
||||
}
|
||||
|
||||
/// Apply an inbound friend request. Returns `true` if it *completes a mutual
|
||||
/// match* — we'd already sent them one, so they're now [`Accepted`] and the
|
||||
/// caller should reply with a `FriendAccept`. Otherwise it's recorded as
|
||||
/// [`PendingIncoming`] for the user to act on and `false` is returned.
|
||||
///
|
||||
/// [`Accepted`]: FriendState::Accepted
|
||||
/// [`PendingIncoming`]: FriendState::PendingIncoming
|
||||
pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool {
|
||||
if matches!(
|
||||
self.find(&id).map(|f| f.state),
|
||||
Some(FriendState::PendingOutgoing)
|
||||
) {
|
||||
self.upsert(id, name, FriendState::Accepted);
|
||||
true
|
||||
} else {
|
||||
self.upsert(id, name, FriendState::PendingIncoming);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an inbound acceptance of a request we sent. Returns `true` if it
|
||||
/// advanced a friendship to [`Accepted`] (i.e. we actually knew this peer);
|
||||
/// an accept from a stranger is ignored.
|
||||
///
|
||||
/// [`Accepted`]: FriendState::Accepted
|
||||
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
|
||||
if self.find(&id).is_some() {
|
||||
self.upsert(id, name, FriendState::Accepted);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_id() -> EndpointId {
|
||||
iroh::SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_toml() {
|
||||
let mut store = FriendStore::default();
|
||||
store.upsert(sample_id(), "Alice".into(), FriendState::Accepted);
|
||||
store.upsert(sample_id(), "Bob".into(), FriendState::PendingIncoming);
|
||||
|
||||
let toml = toml::to_string_pretty(&store).unwrap();
|
||||
let back: FriendStore = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(back.friends, store.friends);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_updates_in_place() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Old".into(), FriendState::PendingOutgoing);
|
||||
store.upsert(id, "New".into(), FriendState::Accepted);
|
||||
assert_eq!(store.friends.len(), 1);
|
||||
let f = store.find(&id).unwrap();
|
||||
assert_eq!(f.name, "New");
|
||||
assert_eq!(f.state, FriendState::Accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_accepted_only_for_accepted_state() {
|
||||
let mut store = FriendStore::default();
|
||||
let pending = sample_id();
|
||||
let friend = sample_id();
|
||||
store.upsert(pending, "P".into(), FriendState::PendingOutgoing);
|
||||
store.upsert(friend, "F".into(), FriendState::Accepted);
|
||||
assert!(!store.is_accepted(&pending));
|
||||
assert!(store.is_accepted(&friend));
|
||||
assert!(!store.is_accepted(&sample_id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_reports_whether_present() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "X".into(), FriendState::Accepted);
|
||||
assert!(store.remove(&id));
|
||||
assert!(!store.remove(&id));
|
||||
assert!(store.friends.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_request_from_stranger_is_pending() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
let mutual = store.on_friend_request(id, "Stranger".into());
|
||||
assert!(!mutual);
|
||||
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_request_matching_our_outgoing_is_mutual() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
// We asked them first…
|
||||
store.upsert(id, "Pal".into(), FriendState::PendingOutgoing);
|
||||
// …then their request arrives — that's a mutual match.
|
||||
let mutual = store.on_friend_request(id, "Pal".into());
|
||||
assert!(mutual);
|
||||
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_advances_known_peer_only() {
|
||||
let mut store = FriendStore::default();
|
||||
let known = sample_id();
|
||||
store.upsert(known, "Known".into(), FriendState::PendingOutgoing);
|
||||
assert!(store.on_friend_accept(known, "Known".into()));
|
||||
assert_eq!(store.find(&known).unwrap().state, FriendState::Accepted);
|
||||
// An accept from someone we never asked is ignored.
|
||||
let stranger = sample_id();
|
||||
assert!(!store.on_friend_accept(stranger, "Nope".into()));
|
||||
assert!(store.find(&stranger).is_none());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ pub mod deps;
|
||||
pub mod display;
|
||||
pub mod endpoint;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod friends;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod identity;
|
||||
pub mod output;
|
||||
pub mod process;
|
||||
|
||||
Reference in New Issue
Block a user