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>
Stand up the friends control plane: a persistent-identity iroh endpoint
that's online for the whole GUI session, separate from the ephemeral
video sessions, ready to carry friend requests and pushed share-codes.
Identity split by plane (common/endpoint.rs): the video plane (host/
viewer) goes back to ephemeral per-session keypairs, while the new
bind_control() binds with the machine's persistent identity. They must
differ — the GUI's control endpoint and a host's video endpoint can be
live at once, and iroh routes by EndpointId, so a shared id would make
relay delivery ambiguous. Bonus: a screen-share now leaks no stable id.
common/control.rs — the protocol: a ControlMsg enum (Hello / Friend
Request / FriendAccept / FriendDecline / ShareCode) with one-message-
per-connection framing (EOF-delimited JSON) and a one-byte ACK the
receiver returns only after a successful parse, so send() gets a real
delivered/failed signal (the basis for the later code-push queue). The
sender id is taken from the connection's verified remote key, never the
payload. send() takes impl Into<EndpointAddr> so production dials a bare
EndpointId (discovery resolves it) while tests use a full addr.
gui/presence.rs — the service: a dedicated thread + current-thread tokio
runtime (mirroring the tray) binds the control endpoint and runs the
accept loop, bridging inbound messages to a std mpsc the UI drains each
tick and pinging the Waker so they land even while hidden to the tray.
The whole friends stack (identity, control, CONTROL_ALPN, bind_control)
is gated behind the `gui` feature — a headless CLI host runs no presence
service — keeping the headless build lean and warning-free.
Verified: loopback test delivers a FriendRequest across two real iroh
endpoints with the correct authenticated sender id; the live GUI binds
its control endpoint on launch under the persistent identity. fmt +
clippy clean on both feature sets; headless and gui test suites pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iroh otherwise mints a fresh keypair every run, so a peer's EndpointId
changed on each launch. The friends system (in progress) identifies
people by that id — the public key already embedded in every share
code — so it has to stay stable across launches and across roles.
Add common::identity: load-or-create an ed25519 secret key stored as
hex in a 0600 ~/.config/pixelpass/identity.key, separate from
config.toml so a config reset or hand-edit can't clobber it. A
malformed file is a hard error rather than a silent regenerate, since
quietly minting a new identity would orphan every existing friend.
endpoint::bind() now feeds this key to the builder, so host and viewer
share one stable EndpointId. Verified end-to-end: two launches against
the same config dir emit byte-identical tickets; a fresh dir differs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both host and viewer hardcoded presets::N0, pinning every session to the
bundled relays (which on iroh rc.0 are the canary-grade defaults). Add a
shared common::endpoint::bind() that keeps N0's DNS discovery + crypto but
swaps in a RelayMode::Custom single-relay map when --relay (or the
PIXELPASS_RELAY env var, so GUI children inherit it) is set.
Lets users point at a self-hosted relay or staging today; the production
relays (*.relay.iroh.network) speak a newer protocol that rc.0 rejects
("invalid iroh-relay version header"), so they only become usable — and
the default — after an iroh GA bump. Verified: override connects cleanly
through staging; bad URLs are rejected before any network work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds common/output.rs: a process-global JSON-lines emitter for
non-interactive front-ends. With --output json, host and viewer emit one
JSON object per line on stdout (ticket, host_info, viewer_count, capture
start/stop, viewer_refused, connected), flushed per line; the human banner
and tracing logs stay on stderr so the two never interleave. No-op when the
flag is absent, so call sites emit unconditionally.
This is the shell-out counterpart to an in-process event channel: the
upcoming --gui front-end re-execs this binary as `pixelpass --host
--output json` and parses these lines to drive its window. serde_json was
already in the tree from the bandwidth pre-flight.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First-run host launch now offers a one-time upstream measurement
against speed.cloudflare.com/__up via ureq (~5 MB POST, ~5s). The
result lives at ~/.config/pixelpass/config.toml under [bandwidth]
and feeds the default --max-viewers calculation on subsequent runs.
Sticky semantics for the dialog:
- Unmeasured: first-run prompt (Run / Skip)
- Measured / Skipped: silent — never re-prompts
- Failed: ask again on next launch (Retry / give up → Skipped)
`pixelpass --reconfigure` re-runs the test unconditionally for users
whose connection has changed (new ISP, moved house, etc.).
--max-viewers is now Option<u32>. When unset, host startup loads the
saved measurement, runs recommended_max_viewers(safe_mbps, bitrate),
and surfaces the source in the banner: "max viewers : N (auto: X.X
Mbps measured upstream)" — or user-specified / default fallback.
User verified end-to-end on 2026-05-21 16:54 EDT: first-run dialog,
skip path, run path, --reconfigure refresh, and banner integration
all work as expected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bare `pixelpass` now opens a dialoguer-driven Host/View menu instead of
going straight to host mode. Host path copies the ticket to the system
clipboard via arboard with silent print-only fallback. View path
prompts for the ticket, then after the local listener binds prompts
mpv-vs-VLC and spawns it detached (setsid + null stdio) so the player
survives pixelpass exiting.
Headless invocations (`pixelpass <ticket>`, `pixelpass --repair`)
unchanged. Per spec at ~/Documents/pixelpass-interactive-mode-spec.md.