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>
Window-focused shortcuts via a handle_keys() dispatch: H/V/S on the menu,
Space/Enter to start hosting and C to copy the code on the Host screen, F1
to open the new Shortcuts screen, and Esc to back out (existing). Letter/Space
actions only fire when no widget holds focus, so they don't clash with typing
or egui's Space/Enter widget activation; popups keep their own key handling.
New Screen::Shortcuts lists every binding behind a menu button. Also enforce
that leaving Settings closes the theme editor, so a draft preview can't leak.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressing Esc on the Host/View screens stops the session and returns to the
menu (mirroring the '← Menu' button); on Settings it returns to the menu, or
closes the theme editor back to the picker if one is open. Suppressed while a
popup (colour picker, dropdown) is open so Esc just dismisses the popup there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The editor was one flat 13-row colour list. Split it into Surfaces / Text &
accent / Buttons / Status colours, each a bold subheading over its own grid,
via a new color_section helper. Pure layout — no behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two theme fields had no visible effect:
- window_bg mapped only to egui's window_fill, but the app draws on the bare
background layer with no panel, so that's never painted — the real backdrop
was a hardcoded GL clear colour. Paint a themed background rect (window_bg)
behind everything in draw() instead.
- weak_text was dead: egui's weak_text_color() derives from the text colour
unless Visuals::weak_text_color is set, which it wasn't. Set it.
Audited the rest (panel/input bg, text, accent, button, hover, and the five
status colours) — those already resolve to the right egui fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Settings screen grew past a short window once the Appearance section
landed, forcing a manual resize to reach the Save button. Wrap the body in a
vertical ScrollArea (header stays pinned), mirroring the Host screen. Also
add a '↺ Defaults' button to the right of Save in the theme editor that
resets the draft to the original Default Dark palette (previews live).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Load the saved theme at startup and apply it to egui's visuals (cloning the
global style so the font scaling is preserved); the egui context persists
across the hide/show window cycle, so it sticks. Route the previously
hardcoded status colours (streaming/waiting/success/warning/error) through
the active theme so a theme re-skins the whole app, not just the chrome (the
QR code stays black-on-white so it remains scannable). Settings gains an
Appearance section: a picker that switches themes live and persists the
choice, and an editor with a colour button per palette field, a live
preview, and Save (writes a .toml). The picker refreshes from disk when
Settings opens, so dropped-in files appear without a restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gui::run() dropped the parsed --relay value, so a relay chosen on the
GUI command line (pixelpass --gui --relay URL) never reached the
headless host/viewer children -- only the PIXELPASS_RELAY env-var form
propagated (via inheritance). Thread the flag into the app and append
--relay <url> to both child arg vectors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `show_qr` preference (default on) to GuiSettings, with a
checkbox in Settings and a corresponding gate on the host-screen render.
Persists to config.toml alongside the existing close-to-tray setting.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The QR panel pushed the Stop hosting button below the fold at the old
520x480 default. Wraps host_running/host_form in a vertical ScrollArea
(header stays pinned) and bumps the initial height to 640 so the common
case fits without scrolling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Encodes the relay-only ticket as a QR with a 4-module quiet zone so a
phone (or a second laptop with a webcam) can pick the room up without
typing 140+ characters. Built lazily on the first draw after a Ticket
event, NEAREST-filtered, 200x200 logical; cleared on session start and
stop.
Pulls `qrcode` 0.14 with `default-features = false` so the heavy `image`
crate tree is skipped — we render modules straight to an
`egui::ColorImage` ourselves.
Reapplies the idea from Gemini's stale `feat/gemini-branch-qrcode`
(`7f07583`) against the post-hand-rolled-loop GUI; the original commit
no longer cherry-picks because gui/mod.rs was rewritten for the
true-Wayland-window-hide work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace eframe::run_native with a winit ApplicationHandler + glutin +
egui_glow loop so "keep running in the tray" can genuinely hide the
window. winit's set_visible(false) is a deliberate no-op on Wayland
(xdg-shell has no unmap-but-keep-alive request), so the only way to hide
a toplevel is to destroy its surface: hide-to-tray now drops the Window +
GL surface (parking the GL context as not-current) and a tray click
recreates them and makes the context current again. The GL context,
glutin display/config, egui_glow painter (uploaded textures), and
egui-winit state (clipboard) all persist across the cycle — only the OS
window and its surface churn.
Wakeups route through winit's EventLoopProxy (the new Waker, and the
tray) instead of egui's repaint callback, so a child event or tray click
wakes the loop even while the window is dropped and no frame is running —
keeping viewer join/leave notifications and the tray tooltip live while
hidden. Removes the old Wayland minimize-to-tray fallback (window stayed
in the taskbar); hide is now uniform on Wayland and X11.
Deps: winit/glutin/glutin-winit/egui_glow promoted to direct (gui-gated,
optional) — all already transitive via eframe, so no new crates. winit's
default features minus wayland-csd-adwaita, so sctk-adwaita/tiny-skia/
ttf-parser aren't pulled for a CSD fallback titlebar (KWin draws
server-side decorations, and eframe never had CSD either).
Verified end-to-end on KWin Wayland: launch->render; close->window AND
taskbar entry gone (true hide, process stays alive); tray activate->
window + GL surface recreated and renders; tray quit->clean exit; stderr
clean throughout. cargo test --features gui: 15 pass; clippy clean;
headless dependency tree unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a StatusNotifierItem tray (ksni — pure-Rust over the zbus stack
notify-rust already pulls; only new crate is the pastey macro helper).
The icon reflects host/viewer status via its tooltip and offers
Show / Quit; it runs on its own thread, channel-wired to the egui app.
Add a Settings screen with a persisted toggle 'keep running in the tray
when I close the window' (config.toml [gui] close_to_tray), defaulting
OFF so the close button quits as users expect. When ON, closing hides
to the tray on X11 / minimizes on Wayland (which has no protocol to hide
a toplevel) and keeps any live stream running. If no tray is present the
close behaves normally, so the window can never be stranded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Set the Wayland app_id to `pixelpass` so the compositor matches the
installed pixelpass.desktop and uses its Icon= in the titlebar/taskbar,
replacing the generic fallback. Also embed a 256px PNG (rendered from
assets/pixelpass.svg) and set it via with_icon for X11 _NET_WM_ICON, and
add StartupWMClass=pixelpass to the desktop entry for robust window↔entry
matching across desktop environments. No new deps — eframe already pulls
the image crate, and icon_data::from_png_bytes decodes the embed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`cargo clippy --fix`: drop needless borrows in interactive.rs, remove an
unneeded `return`, and derive `Default` for `HostState` / the config struct
instead of hand-writing it. No behaviour change.
README: the GUI host screen now lists connected viewers with a Kick button
and notifies on join/leave — update the description, which still mentioned
only a "live viewer count".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The host screen pops a desktop notification on each viewer join/leave,
so you know someone connected while the window is in the background.
Fired on a detached thread (the D-Bus call never touches the egui
frame) and gated on the same viewer-list transitions, so stopping the
host — which drops the child and stops pumping events — doesn't spray a
notification per remaining viewer.
notify-rust's default features give the pure-Rust zbus backend, so this
adds no system libdbus dependency and no GTK event loop (gui feature
only; the headless build is untouched).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Track viewers by endpoint id instead of a bare count. The JSON event
stream gains viewer_joined / viewer_left (each carrying the id),
replacing viewer_count; active/max still ride along so the count
display is unchanged.
The host screen now renders one row per connected viewer with a Kick
button. Clicking it sends `kick <id>` to the headless child over a new
stdin command channel, which the host turns into a per-viewer
CancellationToken cancel; the existing teardown path then emits the
leave, so a kick and a self-disconnect look identical downstream.
The stdin channel only runs under --output json (the GUI shell-out) and
on a detached OS thread, so a read parked on stdin can't hold up the
host's Ctrl+C shutdown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Entering View now grabs keyboard focus on the code field (once, via a
one-shot flag so it doesn't steal focus every frame), so the user can paste
or type the share code immediately without clicking into it first.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the pasted code doesn't decode, Connect is greyed out; hovering it now
shows "Paste a valid share code first." so the disabled state is
self-explanatory, complementing the amber "doesn't look like a share code"
line under the field.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Paste button + code field were wrapped in `with_layout(right_to_left)`,
which grabs the parent's entire remaining height and vertically centers the
row in it — gutting the View screen (field dropped to the middle, button
pinned far right). Use a plain `ui.horizontal` row with the button first and
the field filling the rest via INFINITY width. Same one-click-paste behavior,
correct single-row layout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quick viewer-flow polish:
- a "📋 Paste" button pinned to the right of the code field — the read-side
mirror of the host's Copy button;
- Enter in the code field connects (same decode gate as the button);
- the View screen prefills the field from the clipboard on open when it holds
a decodable ticket and the field is empty, so the freshly-shared code is
usually already there (live decode still shows the id to verify);
- the menu shows the binary version under the heading.
Tightens the common "host clicks Copy → viewer clicks Paste → Connect" loop;
the prefill only ever drops in a *valid* ticket, so it can't reintroduce the
stale/garbage paste it guards against.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The viewer screen now parses the pasted code client-side with the same
`EndpointTicket::from_str` the headless viewer uses, and surfaces what it
finds:
- live preview under the paste box: green "→ endpoint <id>…" for a valid
ticket, amber "doesn't look like a share code" otherwise;
- Connect is gated on a ticket that actually decodes (was: any non-empty
text), so a garbage paste can't burn the 15s connect timeout;
- the connecting line reads "● Connecting to <id>…" instead of a bare
"Connecting…";
- the host screen shows its own "endpoint <id>…" with the same truncation,
so the two ends are eyeball-comparable.
This closes the loop on the stale-ticket trap: a dead/wrong code is now
obvious the moment it's pasted, not 15s later. 5 unit tests cover the
decode (real round-trip ticket) and short-id truncation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The CLI/interactive host auto-copies the ticket to the clipboard and says so;
the GUI host only offered a manual Copy button. Users conditioned by the CLI
assumed the GUI auto-copied too, didn't click Copy, and pasted whatever stale
ticket was already in the clipboard — then dialed a dead host and saw an
unexplained "can't connect". (Compounded by flaky Wayland clipboard / KDE
Connect sync.)
Now the ticket is copied the moment it arrives (same arboard path as the
manual button), with a green "✓ Copied to clipboard" confirmation. Auto-copy
failure is non-fatal: the code stays visible, is now selectable for manual
copy, and a hint tells the user to click Copy. Verified: clicking only Start
lands a fresh ticket in the clipboard (wl-paste).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The GUI now does real work. Host tab: a config form (quality combo,
max-viewers, software-encode + single-window toggles) spawns
`pixelpass --host --output json …` via re-exec, then a background thread
parses the child's JSON events and the window shows live status — ticket
with a copy button, viewer count, streaming/waiting state, host_info
summary, and host-full refusals. Viewer tab: paste a code, pick mpv/VLC,
Connect spawns `pixelpass <ticket> --output json`, and on the connected
event the GUI launches the player (reusing interactive::Player).
ChildProc (gui/child.rs) owns the child: reads stdout events over a
channel, rings the last 60 stderr lines for failure display, and stops via
SIGINT (graceful host teardown) with a 2s grace before SIGKILL — Drop
ensures closing the window never orphans a live host. Five round-trip tests
lock the common::output::Event ↔ ChildEvent wire contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the opt-in graphical front-end (pixelpass --gui), default-off via the
`gui` cargo feature so the headless build never pulls the toolkit tree.
eframe 0.34 on the glow/OpenGL backend (no wgpu); 69 feature-gated crates,
vetted. --gui on a headless build errors with a rebuild hint.
This commit is just the shell: a window with a Host/View menu and back
navigation. The shell-out child-spawning + JSON event parsing that drives
real host/viewer controls come next. Window verified to open and render
cleanly on Wayland (glow).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>