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.
|
/// `~/.config/pixelpass/themes/`). Defaults to the built-in Default Dark.
|
||||||
#[serde(default = "default_theme")]
|
#[serde(default = "default_theme")]
|
||||||
pub theme: String,
|
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 {
|
impl Default for GuiSettings {
|
||||||
@@ -44,6 +48,7 @@ impl Default for GuiSettings {
|
|||||||
close_to_tray: false,
|
close_to_tray: false,
|
||||||
show_qr: true,
|
show_qr: true,
|
||||||
theme: default_theme(),
|
theme: default_theme(),
|
||||||
|
display_name: default_display_name(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +61,15 @@ fn default_theme() -> String {
|
|||||||
"Default Dark".to_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.
|
/// Result of the first-run upstream measurement.
|
||||||
///
|
///
|
||||||
/// `status = "unmeasured"` means we've never asked the user — show the
|
/// `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,
|
/// `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`]
|
/// 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).
|
/// 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(
|
pub async fn send(
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
peer: impl Into<EndpointAddr>,
|
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 display;
|
||||||
pub mod endpoint;
|
pub mod endpoint;
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod friends;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+401
-21
@@ -37,6 +37,7 @@
|
|||||||
//! dropped and no egui frame is running.
|
//! dropped and no egui frame is running.
|
||||||
|
|
||||||
mod child;
|
mod child;
|
||||||
|
mod code;
|
||||||
mod presence;
|
mod presence;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod tray;
|
mod tray;
|
||||||
@@ -68,6 +69,8 @@ use winit::window::{Window, WindowAttributes, WindowId};
|
|||||||
use self::child::{ChildEvent, ChildProc};
|
use self::child::{ChildEvent, ChildProc};
|
||||||
use self::presence::PresenceHandle;
|
use self::presence::PresenceHandle;
|
||||||
use self::tray::{TrayAction, TrayHandle, TrayStatus};
|
use self::tray::{TrayAction, TrayHandle, TrayStatus};
|
||||||
|
use crate::common::control::ControlMsg;
|
||||||
|
use crate::common::friends::FriendState;
|
||||||
|
|
||||||
/// Initial / minimum window size, in logical points. Initial height fits the
|
/// Initial / minimum window size, in logical points. Initial height fits the
|
||||||
/// host screen (ticket + Copy + QR + Stop) without needing to scroll on a 1080p
|
/// host screen (ticket + Copy + QR + Stop) without needing to scroll on a 1080p
|
||||||
@@ -614,6 +617,11 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
|
|||||||
let names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
let names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
||||||
let draft = active.clone();
|
let draft = active.clone();
|
||||||
|
|
||||||
|
let friends = crate::common::friends::load().unwrap_or_else(|e| {
|
||||||
|
tracing::warn!("failed to load friends list: {e:#}");
|
||||||
|
Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let state = PixelPassApp {
|
let state = PixelPassApp {
|
||||||
screen: Screen::default(),
|
screen: Screen::default(),
|
||||||
host: HostState::default(),
|
host: HostState::default(),
|
||||||
@@ -632,6 +640,9 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
|
|||||||
},
|
},
|
||||||
waker,
|
waker,
|
||||||
presence,
|
presence,
|
||||||
|
friends,
|
||||||
|
display_name: gui_settings.display_name,
|
||||||
|
met: Vec::new(),
|
||||||
};
|
};
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
state,
|
state,
|
||||||
@@ -688,6 +699,14 @@ fn short_id(id: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `Hello` control message carrying our display name — the self-introduction
|
||||||
|
/// a viewer sends the host on connect, and the host's reply.
|
||||||
|
fn control_hello(name: &str) -> ControlMsg {
|
||||||
|
ControlMsg::Hello {
|
||||||
|
name: name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fire a desktop notification, on a detached thread so the D-Bus round-trip
|
/// Fire a desktop notification, on a detached thread so the D-Bus round-trip
|
||||||
/// can't stall the egui frame. Best-effort: with no notification daemon it
|
/// can't stall the egui frame. Best-effort: with no notification daemon it
|
||||||
/// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this
|
/// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this
|
||||||
@@ -724,6 +743,14 @@ fn persist_show_qr(value: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_display_name(value: &str) {
|
||||||
|
let mut cfg = crate::common::config::load().unwrap_or_default();
|
||||||
|
cfg.gui.display_name = value.to_string();
|
||||||
|
if let Err(e) = crate::common::config::save(&cfg) {
|
||||||
|
tracing::warn!("failed to save settings: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn persist_theme(name: &str) {
|
fn persist_theme(name: &str) {
|
||||||
let mut cfg = crate::common::config::load().unwrap_or_default();
|
let mut cfg = crate::common::config::load().unwrap_or_default();
|
||||||
cfg.gui.theme = name.to_string();
|
cfg.gui.theme = name.to_string();
|
||||||
@@ -739,6 +766,7 @@ enum Screen {
|
|||||||
Menu,
|
Menu,
|
||||||
Host,
|
Host,
|
||||||
Viewer,
|
Viewer,
|
||||||
|
Friends,
|
||||||
Settings,
|
Settings,
|
||||||
Shortcuts,
|
Shortcuts,
|
||||||
}
|
}
|
||||||
@@ -813,7 +841,13 @@ struct HostState {
|
|||||||
window: bool,
|
window: bool,
|
||||||
// running session + accumulated live state
|
// running session + accumulated live state
|
||||||
proc: Option<ChildProc>,
|
proc: Option<ChildProc>,
|
||||||
|
/// The bare video ticket from the child (used for the host-id fingerprint
|
||||||
|
/// line). The copy/QR/display use [`HostState::share_code`] instead.
|
||||||
ticket: Option<String>,
|
ticket: Option<String>,
|
||||||
|
/// The share code shown/copied/QR'd: the ticket wrapped with our control id
|
||||||
|
/// (see [`code::wrap`]) when the presence service is up, else the bare
|
||||||
|
/// ticket. Wrapping is what lets a viewer offer to befriend the host.
|
||||||
|
share_code: Option<String>,
|
||||||
info: Option<HostInfo>,
|
info: Option<HostInfo>,
|
||||||
active: u32,
|
active: u32,
|
||||||
max: u32,
|
max: u32,
|
||||||
@@ -855,6 +889,10 @@ struct ViewerState {
|
|||||||
/// Short endpoint id we're dialing, decoded from the ticket at Connect.
|
/// Short endpoint id we're dialing, decoded from the ticket at Connect.
|
||||||
/// Shown in the "Connecting to …" line so a dead host is identifiable.
|
/// Shown in the "Connecting to …" line so a dead host is identifiable.
|
||||||
connecting_to: Option<String>,
|
connecting_to: Option<String>,
|
||||||
|
/// The host's stable control id, if the pasted code was a wrapped friend
|
||||||
|
/// code. Lets us announce ourselves to the host (so both ends can befriend)
|
||||||
|
/// once connected. `None` for a bare/CLI ticket.
|
||||||
|
host_control_id: Option<iroh::EndpointId>,
|
||||||
/// Set when the View screen opens so the code field grabs focus once
|
/// Set when the View screen opens so the code field grabs focus once
|
||||||
/// (cleared on use, so it doesn't steal focus every frame).
|
/// (cleared on use, so it doesn't steal focus every frame).
|
||||||
focus_ticket: bool,
|
focus_ticket: bool,
|
||||||
@@ -890,6 +928,20 @@ struct PixelPassApp {
|
|||||||
/// if it couldn't start (no identity), in which case friends features are
|
/// if it couldn't start (no identity), in which case friends features are
|
||||||
/// simply absent.
|
/// simply absent.
|
||||||
presence: Option<PresenceHandle>,
|
presence: Option<PresenceHandle>,
|
||||||
|
/// The persisted friends list (mutual-consent contacts).
|
||||||
|
friends: crate::common::friends::FriendStore,
|
||||||
|
/// Our display name, shown to friends. Persisted in `[gui] display_name`.
|
||||||
|
display_name: String,
|
||||||
|
/// Peers met this session (over a connection) who aren't yet in the friends
|
||||||
|
/// list — drives the "add friend" offer. Session-scoped, not persisted.
|
||||||
|
met: Vec<MetPeer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer encountered this session but not yet befriended.
|
||||||
|
struct MetPeer {
|
||||||
|
id: iroh::EndpointId,
|
||||||
|
/// Their reported display name (a short id placeholder until a name arrives).
|
||||||
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The active theme plus the Settings picker/editor working state.
|
/// The active theme plus the Settings picker/editor working state.
|
||||||
@@ -970,16 +1022,290 @@ impl PixelPassApp {
|
|||||||
self.sync_tray_status();
|
self.sync_tray_status();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain inbound control-plane messages. Phase 3 turns these into friend-list
|
/// Drain inbound control-plane messages and fold them into the friends list
|
||||||
/// state, in-app notifications, and the bell badge; for now they're logged so
|
/// and the session's met-peers, queueing any replies. Collected up front so
|
||||||
/// the control plane is observable end-to-end.
|
/// the presence borrow is released before we mutate `self` / re-borrow it to
|
||||||
|
/// send. (Phase 4 adds the bell badge + ShareCode handling.)
|
||||||
fn pump_presence_events(&mut self) {
|
fn pump_presence_events(&mut self) {
|
||||||
let Some(presence) = &self.presence else {
|
let Some(inbound) = self.presence.as_ref().map(|p| p.drain()) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for inbound in presence.drain() {
|
if inbound.is_empty() {
|
||||||
tracing::info!(from = %inbound.from, msg = ?inbound.msg, "presence: control message");
|
return;
|
||||||
}
|
}
|
||||||
|
let my_name = self.display_name.clone();
|
||||||
|
let mut outbox: Vec<(iroh::EndpointId, ControlMsg)> = Vec::new();
|
||||||
|
let mut store_changed = false;
|
||||||
|
|
||||||
|
for inb in inbound {
|
||||||
|
let from = inb.from;
|
||||||
|
match inb.msg {
|
||||||
|
ControlMsg::Hello { name } => {
|
||||||
|
// A peer announcing themselves (the viewer→host intro, or the
|
||||||
|
// host's reply). Keep a known friend's name fresh; otherwise
|
||||||
|
// record them as a met peer and reply once on first contact.
|
||||||
|
if let Some(f) = self.friends.find_mut(&from) {
|
||||||
|
f.name = name;
|
||||||
|
store_changed = true;
|
||||||
|
} else if self.note_met(from, name) {
|
||||||
|
outbox.push((from, control_hello(&my_name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ControlMsg::FriendRequest { name } => {
|
||||||
|
self.note_met(from, name.clone());
|
||||||
|
if self.friends.on_friend_request(from, name.clone()) {
|
||||||
|
// We'd already requested them — mutual, so it's settled.
|
||||||
|
outbox.push((
|
||||||
|
from,
|
||||||
|
ControlMsg::FriendAccept {
|
||||||
|
name: my_name.clone(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
notify(
|
||||||
|
"PixelPass — now friends",
|
||||||
|
format!("You and {name} are now friends."),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
notify(
|
||||||
|
"PixelPass — friend request",
|
||||||
|
format!("{name} wants to be friends."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
store_changed = true;
|
||||||
|
}
|
||||||
|
ControlMsg::FriendAccept { name } => {
|
||||||
|
if self.friends.on_friend_accept(from, name.clone()) {
|
||||||
|
store_changed = true;
|
||||||
|
notify(
|
||||||
|
"PixelPass — request accepted",
|
||||||
|
format!("{name} accepted your friend request."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ControlMsg::FriendDecline => {
|
||||||
|
if self.friends.remove(&from) {
|
||||||
|
store_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ControlMsg::ShareCode { name, ticket } => {
|
||||||
|
// Phase 4 turns this into the bell badge + an in-app notice.
|
||||||
|
// For now, only honour codes from accepted friends and log.
|
||||||
|
if self.friends.is_accepted(&from) {
|
||||||
|
tracing::info!(from = %from, %name, "presence: friend shared a code: {ticket}");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if store_changed {
|
||||||
|
self.save_friends();
|
||||||
|
}
|
||||||
|
if let Some(p) = &self.presence {
|
||||||
|
for (peer, msg) in outbox {
|
||||||
|
p.send(peer, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a peer met this session. Returns true if they were newly added
|
||||||
|
/// (false if we already knew them, in which case the name is refreshed).
|
||||||
|
fn note_met(&mut self, id: iroh::EndpointId, name: String) -> bool {
|
||||||
|
if let Some(m) = self.met.iter_mut().find(|m| m.id == id) {
|
||||||
|
// Don't overwrite a real name with a short-id placeholder.
|
||||||
|
if !name.is_empty() {
|
||||||
|
m.name = name;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.met.push(MetPeer { id, name });
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_incoming_count(&self) -> usize {
|
||||||
|
self.friends
|
||||||
|
.friends
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.state == FriendState::PendingIncoming)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_friends(&self) {
|
||||||
|
if let Err(e) = self.friends.save() {
|
||||||
|
tracing::warn!("failed to save friends list: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a friend request to a peer (or accept theirs if they already asked),
|
||||||
|
/// persisting the new state and notifying the peer over the control plane.
|
||||||
|
fn request_friend(&mut self, id: iroh::EndpointId, name: String) {
|
||||||
|
let my_name = self.display_name.clone();
|
||||||
|
let msg = if matches!(
|
||||||
|
self.friends.find(&id).map(|f| f.state),
|
||||||
|
Some(FriendState::PendingIncoming)
|
||||||
|
) {
|
||||||
|
self.friends.upsert(id, name, FriendState::Accepted);
|
||||||
|
ControlMsg::FriendAccept { name: my_name }
|
||||||
|
} else {
|
||||||
|
self.friends.upsert(id, name, FriendState::PendingOutgoing);
|
||||||
|
ControlMsg::FriendRequest { name: my_name }
|
||||||
|
};
|
||||||
|
self.save_friends();
|
||||||
|
if let Some(p) = &self.presence {
|
||||||
|
p.send(id, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an incoming request accepted and tell the peer.
|
||||||
|
fn accept_friend(&mut self, id: iroh::EndpointId) {
|
||||||
|
let my_name = self.display_name.clone();
|
||||||
|
if let Some(f) = self.friends.find_mut(&id) {
|
||||||
|
f.state = FriendState::Accepted;
|
||||||
|
self.save_friends();
|
||||||
|
if let Some(p) = &self.presence {
|
||||||
|
p.send(id, ControlMsg::FriendAccept { name: my_name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a friend / decline a request / cancel an outgoing one, telling the
|
||||||
|
/// peer so their side drops us too.
|
||||||
|
fn remove_friend(&mut self, id: iroh::EndpointId) {
|
||||||
|
if self.friends.remove(&id) {
|
||||||
|
self.save_friends();
|
||||||
|
if let Some(p) = &self.presence {
|
||||||
|
p.send(id, ControlMsg::FriendDecline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The "people you just connected with" offer shown on the running host /
|
||||||
|
/// viewer screens: met peers not yet in the friends list, each with an Add
|
||||||
|
/// button.
|
||||||
|
fn friend_offers(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let offers: Vec<(iroh::EndpointId, String)> = self
|
||||||
|
.met
|
||||||
|
.iter()
|
||||||
|
.filter(|m| self.friends.find(&m.id).is_none())
|
||||||
|
.map(|m| (m.id, m.name.clone()))
|
||||||
|
.collect();
|
||||||
|
if offers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.label("People you just connected with:");
|
||||||
|
let mut add: Option<(iroh::EndpointId, String)> = None;
|
||||||
|
for (id, name) in &offers {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(name.as_str());
|
||||||
|
if ui.small_button("➕ Add friend").clicked() {
|
||||||
|
add = Some((*id, name.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some((id, name)) = add {
|
||||||
|
self.request_friend(id, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn friends_screen(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("← Menu").clicked() {
|
||||||
|
self.screen = Screen::Menu;
|
||||||
|
}
|
||||||
|
ui.heading("Friends");
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
// Your identity: editable display name + your stable id.
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Your name");
|
||||||
|
if ui
|
||||||
|
.text_edit_singleline(&mut self.display_name)
|
||||||
|
.on_hover_text("Shown to friends in requests and shared codes.")
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
persist_display_name(&self.display_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(p) = &self.presence {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("Your ID: {}", short_id(&p.id().to_string())))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.colored_label(
|
||||||
|
self.theme.active.warning,
|
||||||
|
"⚠ Friends service unavailable (no identity).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
if self.friends.friends.is_empty() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"No friends yet. Connect with someone, then use \"Add friend\" \
|
||||||
|
on the host/view screen.",
|
||||||
|
)
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the chosen action first so we don't mutate the store while
|
||||||
|
// iterating it.
|
||||||
|
enum Action {
|
||||||
|
Accept(iroh::EndpointId),
|
||||||
|
Remove(iroh::EndpointId),
|
||||||
|
}
|
||||||
|
let mut action: Option<Action> = None;
|
||||||
|
for f in &self.friends.friends {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(egui::RichText::new(&f.name).strong());
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("· {}", short_id(&f.id.to_string())))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
match f.state {
|
||||||
|
FriendState::Accepted => {
|
||||||
|
ui.label(egui::RichText::new("· friend").small().weak());
|
||||||
|
if ui.small_button("Remove").clicked() {
|
||||||
|
action = Some(Action::Remove(f.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FriendState::PendingIncoming => {
|
||||||
|
ui.label(egui::RichText::new("· wants to be friends").small().weak());
|
||||||
|
if ui.small_button("Accept").clicked() {
|
||||||
|
action = Some(Action::Accept(f.id));
|
||||||
|
}
|
||||||
|
if ui.small_button("Decline").clicked() {
|
||||||
|
action = Some(Action::Remove(f.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FriendState::PendingOutgoing => {
|
||||||
|
ui.label(egui::RichText::new("· request sent").small().weak());
|
||||||
|
if ui.small_button("Cancel").clicked() {
|
||||||
|
action = Some(Action::Remove(f.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match action {
|
||||||
|
Some(Action::Accept(id)) => self.accept_friend(id),
|
||||||
|
Some(Action::Remove(id)) => self.remove_friend(id),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the current screen. Called from inside the egui frame.
|
/// Render the current screen. Called from inside the egui frame.
|
||||||
@@ -1018,6 +1344,7 @@ impl PixelPassApp {
|
|||||||
Screen::Menu => self.menu(ui),
|
Screen::Menu => self.menu(ui),
|
||||||
Screen::Host => self.host(ui),
|
Screen::Host => self.host(ui),
|
||||||
Screen::Viewer => self.viewer(ui),
|
Screen::Viewer => self.viewer(ui),
|
||||||
|
Screen::Friends => self.friends_screen(ui),
|
||||||
Screen::Settings => self.settings(ui),
|
Screen::Settings => self.settings(ui),
|
||||||
Screen::Shortcuts => self.shortcuts(ui),
|
Screen::Shortcuts => self.shortcuts(ui),
|
||||||
}
|
}
|
||||||
@@ -1051,7 +1378,9 @@ impl PixelPassApp {
|
|||||||
self.screen = Screen::Menu;
|
self.screen = Screen::Menu;
|
||||||
}
|
}
|
||||||
Screen::Settings if self.theme.editing => self.cancel_theme_edit(),
|
Screen::Settings if self.theme.editing => self.cancel_theme_edit(),
|
||||||
Screen::Settings | Screen::Shortcuts => self.screen = Screen::Menu,
|
Screen::Settings | Screen::Shortcuts | Screen::Friends => {
|
||||||
|
self.screen = Screen::Menu
|
||||||
|
}
|
||||||
Screen::Menu => {}
|
Screen::Menu => {}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1076,16 +1405,16 @@ impl PixelPassApp {
|
|||||||
Screen::Host => {
|
Screen::Host => {
|
||||||
if self.host.proc.is_some() {
|
if self.host.proc.is_some() {
|
||||||
if key(Key::C)
|
if key(Key::C)
|
||||||
&& let Some(ticket) = self.host.ticket.clone()
|
&& let Some(code) = self.host.share_code.clone()
|
||||||
{
|
{
|
||||||
self.copy_to_clipboard(&ticket);
|
self.copy_to_clipboard(&code);
|
||||||
}
|
}
|
||||||
} else if key(Key::Space) || key(Key::Enter) {
|
} else if key(Key::Space) || key(Key::Enter) {
|
||||||
self.start_host();
|
self.start_host();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// View's Enter (Connect) is handled in viewer_form.
|
// View's Enter (Connect) is handled in viewer_form.
|
||||||
Screen::Viewer | Screen::Settings | Screen::Shortcuts => {}
|
Screen::Viewer | Screen::Friends | Screen::Settings | Screen::Shortcuts => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,6 +1508,16 @@ impl PixelPassApp {
|
|||||||
self.prefill_viewer_ticket();
|
self.prefill_viewer_ticket();
|
||||||
}
|
}
|
||||||
ui.add_space(20.0);
|
ui.add_space(20.0);
|
||||||
|
let pending = self.pending_incoming_count();
|
||||||
|
let friends_label = if pending > 0 {
|
||||||
|
format!("👥 Friends ({pending})")
|
||||||
|
} else {
|
||||||
|
"👥 Friends".to_string()
|
||||||
|
};
|
||||||
|
if ui.button(friends_label).clicked() {
|
||||||
|
self.screen = Screen::Friends;
|
||||||
|
}
|
||||||
|
ui.add_space(8.0);
|
||||||
if ui.button("⚙ Settings").clicked() {
|
if ui.button("⚙ Settings").clicked() {
|
||||||
// Refresh the picker so themes added to the folder since launch
|
// Refresh the picker so themes added to the folder since launch
|
||||||
// (or last visit) show up without a restart.
|
// (or last visit) show up without a restart.
|
||||||
@@ -1554,9 +1893,9 @@ impl PixelPassApp {
|
|||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
if let Some(ticket) = self.host.ticket.clone() {
|
if let Some(share_code) = self.host.share_code.clone() {
|
||||||
ui.label("Share this code with your viewer(s):");
|
ui.label("Share this code with your viewer(s):");
|
||||||
if let Some(id) = ticket_endpoint_id(&ticket) {
|
if let Some(id) = self.host.ticket.as_deref().and_then(ticket_endpoint_id) {
|
||||||
// The viewer shows "Connecting to <id>…" with this same
|
// The viewer shows "Connecting to <id>…" with this same
|
||||||
// truncation, so the two ends can be eyeballed for a match.
|
// truncation, so the two ends can be eyeballed for a match.
|
||||||
ui.label(
|
ui.label(
|
||||||
@@ -1568,7 +1907,7 @@ impl PixelPassApp {
|
|||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(egui::RichText::new(&ticket).monospace().small())
|
egui::Label::new(egui::RichText::new(&share_code).monospace().small())
|
||||||
.wrap()
|
.wrap()
|
||||||
.selectable(true),
|
.selectable(true),
|
||||||
);
|
);
|
||||||
@@ -1576,7 +1915,7 @@ impl PixelPassApp {
|
|||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("📋 Copy code").clicked() {
|
if ui.button("📋 Copy code").clicked() {
|
||||||
self.copy_to_clipboard(&ticket);
|
self.copy_to_clipboard(&share_code);
|
||||||
}
|
}
|
||||||
if self.host.copied {
|
if self.host.copied {
|
||||||
ui.colored_label(self.theme.active.success, "✓ Copied to clipboard");
|
ui.colored_label(self.theme.active.success, "✓ Copied to clipboard");
|
||||||
@@ -1593,13 +1932,13 @@ impl PixelPassApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lazy QR build: first draw after a Ticket event has `qr_texture =
|
// Lazy QR build: first draw after a Ticket event has `qr_texture =
|
||||||
// None`, so we encode the ticket and load the texture once. The
|
// None`, so we encode the share code and load the texture once. The
|
||||||
// 4-module quiet zone (white border) matters — phone scanners reject
|
// 4-module quiet zone (white border) matters — phone scanners reject
|
||||||
// QR codes flush against a non-white edge. Skipped when the user
|
// QR codes flush against a non-white edge. Skipped when the user
|
||||||
// disabled the QR panel in Settings.
|
// disabled the QR panel in Settings.
|
||||||
if self.show_qr
|
if self.show_qr
|
||||||
&& self.host.qr_texture.is_none()
|
&& self.host.qr_texture.is_none()
|
||||||
&& let Ok(code) = qrcode::QrCode::new(ticket.as_bytes())
|
&& let Ok(code) = qrcode::QrCode::new(share_code.as_bytes())
|
||||||
{
|
{
|
||||||
let w = code.width();
|
let w = code.width();
|
||||||
let quiet = 4;
|
let quiet = 4;
|
||||||
@@ -1633,6 +1972,8 @@ impl PixelPassApp {
|
|||||||
ui.colored_label(self.theme.active.warning, format!("⚠ {reason}"));
|
ui.colored_label(self.theme.active.warning, format!("⚠ {reason}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.friend_offers(ui);
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
if ui
|
if ui
|
||||||
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
|
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
|
||||||
@@ -1646,6 +1987,7 @@ impl PixelPassApp {
|
|||||||
self.host.error = None;
|
self.host.error = None;
|
||||||
self.host.last_refusal = None;
|
self.host.last_refusal = None;
|
||||||
self.host.ticket = None;
|
self.host.ticket = None;
|
||||||
|
self.host.share_code = None;
|
||||||
self.host.info = None;
|
self.host.info = None;
|
||||||
self.host.active = 0;
|
self.host.active = 0;
|
||||||
self.host.max = 0;
|
self.host.max = 0;
|
||||||
@@ -1653,6 +1995,7 @@ impl PixelPassApp {
|
|||||||
self.host.copied = false;
|
self.host.copied = false;
|
||||||
self.host.viewers.clear();
|
self.host.viewers.clear();
|
||||||
self.host.qr_texture = None;
|
self.host.qr_texture = None;
|
||||||
|
self.met.clear();
|
||||||
|
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
@@ -1687,9 +2030,11 @@ impl PixelPassApp {
|
|||||||
self.host.proc = None;
|
self.host.proc = None;
|
||||||
self.host.capturing = false;
|
self.host.capturing = false;
|
||||||
self.host.ticket = None;
|
self.host.ticket = None;
|
||||||
|
self.host.share_code = None;
|
||||||
self.host.copied = false;
|
self.host.copied = false;
|
||||||
self.host.viewers.clear();
|
self.host.viewers.clear();
|
||||||
self.host.qr_texture = None;
|
self.host.qr_texture = None;
|
||||||
|
self.met.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain the host child's event channel into state, and detect an
|
/// Drain the host child's event channel into state, and detect an
|
||||||
@@ -1723,13 +2068,21 @@ impl PixelPassApp {
|
|||||||
fn apply_host_event(&mut self, ev: ChildEvent) {
|
fn apply_host_event(&mut self, ev: ChildEvent) {
|
||||||
match ev {
|
match ev {
|
||||||
ChildEvent::Ticket { value } => {
|
ChildEvent::Ticket { value } => {
|
||||||
|
// Wrap the bare ticket with our stable control id so a viewer
|
||||||
|
// learns who to befriend (see code::wrap). Falls back to the
|
||||||
|
// bare ticket if the presence service isn't up.
|
||||||
|
let share_code = match &self.presence {
|
||||||
|
Some(p) => code::wrap(p.id(), &value),
|
||||||
|
None => value.clone(),
|
||||||
|
};
|
||||||
// Auto-copy on arrival, mirroring the CLI/interactive host
|
// Auto-copy on arrival, mirroring the CLI/interactive host
|
||||||
// (which copies the ticket and prints "copied to your
|
// (which copies the ticket and prints "copied to your
|
||||||
// clipboard"). A failure here is non-fatal: the ticket stays
|
// clipboard"). A failure here is non-fatal: the code stays
|
||||||
// visible for manual copy, and `copied` stays false so the UI
|
// visible for manual copy, and `copied` stays false so the UI
|
||||||
// doesn't falsely claim success.
|
// doesn't falsely claim success.
|
||||||
self.host.copied = set_clipboard(&value);
|
self.host.copied = set_clipboard(&share_code);
|
||||||
self.host.ticket = Some(value);
|
self.host.ticket = Some(value);
|
||||||
|
self.host.share_code = Some(share_code);
|
||||||
}
|
}
|
||||||
ChildEvent::HostInfo {
|
ChildEvent::HostInfo {
|
||||||
display_server,
|
display_server,
|
||||||
@@ -1855,9 +2208,11 @@ impl PixelPassApp {
|
|||||||
|
|
||||||
// Decode the pasted code live: confirms it's a real ticket and shows
|
// Decode the pasted code live: confirms it's a real ticket and shows
|
||||||
// which host it points at, so a stale clipboard paste is caught here
|
// which host it points at, so a stale clipboard paste is caught here
|
||||||
// instead of after the 15s connect timeout.
|
// instead of after the 15s connect timeout. Unwrap first so a wrapped
|
||||||
|
// friend code decodes to its underlying ticket.
|
||||||
let trimmed = self.viewer.ticket_input.trim().to_string();
|
let trimmed = self.viewer.ticket_input.trim().to_string();
|
||||||
let decoded_id = ticket_endpoint_id(&trimmed);
|
let (host_ctrl, bare_ticket) = code::unwrap(&trimmed);
|
||||||
|
let decoded_id = ticket_endpoint_id(&bare_ticket);
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
match &decoded_id {
|
match &decoded_id {
|
||||||
@@ -1870,6 +2225,13 @@ impl PixelPassApp {
|
|||||||
"⚠ This doesn't look like a share code.",
|
"⚠ This doesn't look like a share code.",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
if host_ctrl.is_some() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("This host can be added as a friend after you connect.")
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(10.0);
|
ui.add_space(10.0);
|
||||||
@@ -1915,6 +2277,8 @@ impl PixelPassApp {
|
|||||||
ui.colored_label(self.theme.active.waiting, msg);
|
ui.colored_label(self.theme.active.waiting, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.friend_offers(ui);
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
if ui
|
if ui
|
||||||
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
|
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
|
||||||
@@ -1946,7 +2310,11 @@ impl PixelPassApp {
|
|||||||
self.viewer.url = None;
|
self.viewer.url = None;
|
||||||
self.viewer.launched = false;
|
self.viewer.launched = false;
|
||||||
|
|
||||||
let ticket = self.viewer.ticket_input.trim().to_string();
|
// Unwrap a wrapped friend code into (host control id, bare ticket). The
|
||||||
|
// child only ever sees the bare ticket; the control id is kept so we can
|
||||||
|
// announce ourselves to the host once connected.
|
||||||
|
let (host_ctrl, ticket) = code::unwrap(&self.viewer.ticket_input);
|
||||||
|
self.viewer.host_control_id = host_ctrl;
|
||||||
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
|
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
|
||||||
let mut args = vec![ticket, "--output".to_string(), "json".to_string()];
|
let mut args = vec![ticket, "--output".to_string(), "json".to_string()];
|
||||||
if let Some(relay) = &self.relay {
|
if let Some(relay) = &self.relay {
|
||||||
@@ -1964,6 +2332,8 @@ impl PixelPassApp {
|
|||||||
self.viewer.url = None;
|
self.viewer.url = None;
|
||||||
self.viewer.launched = false;
|
self.viewer.launched = false;
|
||||||
self.viewer.connecting_to = None;
|
self.viewer.connecting_to = None;
|
||||||
|
self.viewer.host_control_id = None;
|
||||||
|
self.met.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pump_viewer_events(&mut self) {
|
fn pump_viewer_events(&mut self) {
|
||||||
@@ -1980,6 +2350,16 @@ impl PixelPassApp {
|
|||||||
Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")),
|
Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If this was a wrapped friend code, announce ourselves to the
|
||||||
|
// host over the control plane so both ends can offer to befriend
|
||||||
|
// each other. We record the host as a met peer up front (name
|
||||||
|
// filled in when the host's Hello reply arrives).
|
||||||
|
if let Some(host_id) = self.viewer.host_control_id {
|
||||||
|
self.note_met(host_id, short_id(&host_id.to_string()));
|
||||||
|
if let Some(p) = &self.presence {
|
||||||
|
p.send(host_id, control_hello(&self.display_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-5
@@ -18,24 +18,50 @@ use tokio::sync::mpsc as tmpsc;
|
|||||||
|
|
||||||
use super::Waker;
|
use super::Waker;
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
control::{self, Inbound},
|
control::{self, ControlMsg, Inbound},
|
||||||
endpoint, identity,
|
endpoint, identity,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// An outbound control message the UI asks the service to deliver.
|
||||||
|
struct Outbound {
|
||||||
|
peer: EndpointId,
|
||||||
|
msg: ControlMsg,
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle the GUI holds for the presence service. Dropping it doesn't stop the
|
/// Handle the GUI holds for the presence service. Dropping it doesn't stop the
|
||||||
/// service (the thread is detached; the endpoint closes when the process exits)
|
/// service (the thread is detached; the endpoint closes when the process exits)
|
||||||
/// — it just stops the UI from draining inbound messages.
|
/// — it just stops the UI from draining inbound messages.
|
||||||
pub struct PresenceHandle {
|
pub struct PresenceHandle {
|
||||||
|
/// Our stable control-plane id — what friends know us by, and what we embed
|
||||||
|
/// in a wrapped share code so a viewer can find us.
|
||||||
|
id: EndpointId,
|
||||||
/// Inbound control messages, drained by [`PresenceHandle::drain`] each tick.
|
/// Inbound control messages, drained by [`PresenceHandle::drain`] each tick.
|
||||||
rx: Receiver<Inbound>,
|
rx: Receiver<Inbound>,
|
||||||
|
/// Outbound requests handed to the service thread. Unbounded tokio sender so
|
||||||
|
/// the sync UI can enqueue without blocking or being inside the runtime.
|
||||||
|
out_tx: tmpsc::UnboundedSender<Outbound>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PresenceHandle {
|
impl PresenceHandle {
|
||||||
|
/// Our stable control-plane id.
|
||||||
|
pub fn id(&self) -> EndpointId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull every control message received since the last call. Collected by the
|
/// Pull every control message received since the last call. Collected by the
|
||||||
/// caller so it can take `&mut self` while handling them.
|
/// caller so it can take `&mut self` while handling them.
|
||||||
pub fn drain(&self) -> Vec<Inbound> {
|
pub fn drain(&self) -> Vec<Inbound> {
|
||||||
std::iter::from_fn(|| self.rx.try_recv().ok()).collect()
|
std::iter::from_fn(|| self.rx.try_recv().ok()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enqueue a message for delivery to `peer`. Fire-and-forget from the UI's
|
||||||
|
/// view; the service connects, delivers, and logs a failure (Phase 4 adds a
|
||||||
|
/// retry queue). A send error here only means the service thread is gone.
|
||||||
|
pub fn send(&self, peer: EndpointId, msg: ControlMsg) {
|
||||||
|
if self.out_tx.send(Outbound { peer, msg }).is_err() {
|
||||||
|
tracing::warn!("presence: service thread gone; dropping outbound message");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the presence service. Returns `None` if the persistent identity can't
|
/// Start the presence service. Returns `None` if the persistent identity can't
|
||||||
@@ -54,18 +80,26 @@ pub fn start(waker: Waker, relay: Option<String>) -> Option<PresenceHandle> {
|
|||||||
tracing::info!(%id, "presence: starting control service");
|
tracing::info!(%id, "presence: starting control service");
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<Inbound>();
|
let (tx, rx) = mpsc::channel::<Inbound>();
|
||||||
|
let (out_tx, out_rx) = tmpsc::unbounded_channel::<Outbound>();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("pixelpass-presence".into())
|
.name("pixelpass-presence".into())
|
||||||
.spawn(move || run(relay, id, tx, waker))
|
.spawn(move || run(relay, id, tx, out_rx, waker))
|
||||||
.map_err(|e| tracing::warn!("presence: could not spawn service thread: {e}"))
|
.map_err(|e| tracing::warn!("presence: could not spawn service thread: {e}"))
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
Some(PresenceHandle { rx })
|
Some(PresenceHandle { id, rx, out_tx })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thread body: a current-thread tokio runtime that binds the control endpoint,
|
/// Thread body: a current-thread tokio runtime that binds the control endpoint,
|
||||||
/// runs the accept loop, and bridges inbound messages to the UI channel.
|
/// runs the accept loop, bridges inbound messages to the UI channel, and
|
||||||
fn run(relay: Option<String>, id: EndpointId, tx: mpsc::Sender<Inbound>, waker: Waker) {
|
/// delivers outbound messages the UI enqueues.
|
||||||
|
fn run(
|
||||||
|
relay: Option<String>,
|
||||||
|
id: EndpointId,
|
||||||
|
tx: mpsc::Sender<Inbound>,
|
||||||
|
mut out_rx: tmpsc::UnboundedReceiver<Outbound>,
|
||||||
|
waker: Waker,
|
||||||
|
) {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
@@ -99,7 +133,22 @@ fn run(relay: Option<String>, id: EndpointId, tx: mpsc::Sender<Inbound>, waker:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deliver outbound messages the UI enqueues, each on its own task so a
|
||||||
|
// slow/offline peer doesn't hold up the others.
|
||||||
|
let send_ep = ep.clone();
|
||||||
|
let sender = tokio::spawn(async move {
|
||||||
|
while let Some(out) = out_rx.recv().await {
|
||||||
|
let ep = send_ep.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = control::send(&ep, out.peer, &out.msg).await {
|
||||||
|
tracing::warn!(peer = %out.peer, "presence: outbound send failed: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
control::serve(ep, itx).await;
|
control::serve(ep, itx).await;
|
||||||
forward.abort();
|
forward.abort();
|
||||||
|
sender.abort();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user