Commit Graph

71 Commits

Author SHA1 Message Date
mollusk 14fc1af716 style: apply current rustfmt to the tree
A newer rustfmt wraps over-long match arms and call expressions that the
version main was last formatted with left on one line. Pure formatting,
no semantic change — split out so the friends-list feature commits stay
focused on real changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:25:33 -04:00
mollusk 9b9328f6a9 feat(identity): persist a stable node identity across launches
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>
2026-05-30 15:55:09 -04:00
mollusk 2d0143f1aa feat(gui): keyboard shortcuts + a Shortcuts reference screen
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>
2026-05-29 05:21:47 -04:00
mollusk e8273b364e feat(gui): Esc backs out one level
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>
2026-05-29 05:10:16 -04:00
mollusk 472991c11f feat(gui): group the theme editor into labelled sections
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>
2026-05-29 05:02:05 -04:00
mollusk ccd8c33f81 fix(gui): make the window background and weak-text colours actually apply
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>
2026-05-29 04:04:33 -04:00
mollusk c876c61ec6 fix(gui): scroll the Settings body and add a Defaults reset to the editor
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>
2026-05-29 03:09:40 -04:00
mollusk 40960c7476 feat(gui): apply themes live + theme picker and in-app editor
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>
2026-05-29 02:51:03 -04:00
mollusk 0a4bb554e9 feat(gui): add a colour theme model with built-ins and file I/O
New gui::theme module: a Theme is a curated semantic palette (backgrounds,
text, accent, button, and the status colours) that serialises to TOML with
#rrggbb hex colours and builds an egui::Visuals. Missing fields fall back to
the built-in Default Dark via #[serde(default)], so partial/hand-trimmed
files still load. Three built-ins ship (Default Dark, Catppuccin Mocha,
Catppuccin Latte); user themes live as *.toml in ~/.config/pixelpass/themes/
and a user file overrides a built-in of the same name. Adds a `theme` field
to the GUI config (default "Default Dark"). Zero new deps (toml + a few
lines of hex parsing). 6 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:51:03 -04:00
mollusk 9d685c2c48 feat(gui): forward --relay flag to host/viewer children
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>
2026-05-29 02:23:25 -04:00
mollusk 42ffe8928c fix(deps): recognise Artix and Garuda for install hints
detect_distro only matched arch/cachyos/manjaro/endeavouros, so on
Artix (Friend 2's box) and Garuda the missing-dependency hints fell
through to the generic message instead of a pacman command. Both are
pacman-based with identical package names, so add them to every
Arch-family match arm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:23:25 -04:00
mollusk eb077d81f0 feat(relay): add --relay / PIXELPASS_RELAY override
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>
2026-05-28 15:59:36 -04:00
mollusk 32131b0ccb fix(bandwidth): floor recommended viewers to 1 on non-finite input
recommended_max_viewers() promises "at least 1", but a NaN safe_mbps cast
to 0 and an infinite one to u32::MAX. Guard non-finite / non-positive
inputs up front. Add unit tests covering the normal path, the floor, and
the NaN/Inf/negative degenerate cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:40:05 -04:00
mollusk 035aa4b256 fix(signal): warn instead of silently dropping a failed ctrl-c handler
install_ctrl_c() used `if ctrl_c().await.is_ok()`, so if the handler
failed to install, ctrl-c silently stopped working with no diagnostic.
Match on the Result and log a warning (then bail the task — the second
arm would only fail the same way).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:40:05 -04:00
mollusk f85c0c22c7 refactor(audio): dedup Routing teardown into a shared cleanup()
shutdown() and Drop had byte-identical bodies that had to be kept in
sync. Extract a private cleanup(&mut self); shutdown() consumes self and
calls it, Drop calls it as the backstop. Every step is a take(), so the
second run is a no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:40:05 -04:00
mollusk c30418a0f5 fix(gui/tray): track watcher loss and never strand the window
The `registered` flag was one-way: set true once the tray registered, but
never cleared if the StatusNotifierWatcher later disappeared (panel restart,
tray plugin disabled, tray app killed). After that, a Wayland close-to-tray
would destroy the window into a tray that no longer exists, with no recovery
short of SIGTERM.

Implement ksni's `watcher_online`/`watcher_offline` callbacks to keep the
shared flag in sync. On offline, also force the window back (idempotent
`show()`) so a window that was already hidden when the watcher died isn't
stranded, and return true to keep the service alive for re-registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:33:11 -04:00
mollusk 14245cbf08 fix(viewer): close the endpoint on all post-connect failure paths
open_bi/bind/local_addr/accept all `?`-propagated straight out of run(),
skipping the endpoint.close().await at the end and leaking the iroh
Endpoint on any post-connect error. Wrap the post-connect body in one
block whose result is captured, then close unconditionally — matching
the explicit-close idiom of the connect-phase select arms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:24:47 -04:00
mollusk a740376ea9 fix(serve): continue past transient accept errors
Previously a single EMFILE / EINTR on listener.accept() returned from
run_accept_loop entirely, killing the host's HTTP viewer fanout for the
rest of the session. Most accept errors are transient — log and loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 05:52:48 -04:00
mollusk e1ed89026d gui: Settings toggle to hide the host QR-code panel
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>
2026-05-28 05:38:58 -04:00
mollusk a7ea1fd9df gui: scroll the host body and grow the default window for the QR
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>
2026-05-28 05:31:13 -04:00
mollusk 7a03dee12f gui: QR-code panel for the host ticket
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>
2026-05-28 05:07:14 -04:00
mollusk 8cd2d63a87 gui: Increase default font size and use Noto Sans 2026-05-27 03:23:20 -04:00
mollusk 511927569b feat(gui): hand-rolled winit loop for true window-hide on Wayland
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>
2026-05-26 15:41:38 -04:00
mollusk b260d57dc4 feat(gui): system tray with opt-in close-to-tray setting
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>
2026-05-26 05:54:06 -04:00
mollusk ad70ce5ea9 fix(gui): give the window a real icon instead of the Wayland fallback
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>
2026-05-26 05:24:25 -04:00
mollusk 675f25f266 chore: clear clippy warnings and refresh the GUI README
`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>
2026-05-25 16:42:35 -04:00
mollusk e54d625f2a fix(gui): close the window on the first click while hosting
Closing the window while a host/viewer child was running took two clicks:
the first only dropped the stream, the second actually closed the window.

eframe drops the app synchronously while it destroys the window, which ran
`ChildProc`'s teardown — SIGINT plus a up-to-2s grace-period wait — on the
event-loop thread. That wait froze the window mid-close, so the first click
looked like it only killed the stream and the window lingered until a second
close event. (The teardown runs from `Drop`, not from an `on_exit` /
`close_requested` hook, so it fires on every backend and close path; those
hooks don't fire at all under some winit backends.)

Make the teardown non-blocking: hold the child in an `Option`, and on drop
SIGINT it synchronously (so the host still runs its ctrl-c teardown even if
we exit immediately after) then reap it on a detached thread instead of
waiting inline. The app drops instantly, so the window closes on the first
click; the kicked-off SIGINT still tears the stream down cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:23 -04:00
mollusk f926dbea4e feat(gui): desktop notification when a viewer joins or leaves
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>
2026-05-25 15:31:48 -04:00
mollusk 24e0d0e799 feat(gui): list connected viewers and let the host kick them
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>
2026-05-25 15:27:49 -04:00
mollusk e8f86b0ac2 feat(gui): focus the viewer code field when the View screen opens
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>
2026-05-25 03:50:02 -04:00
mollusk 5d519ede78 feat(gui): explain the disabled Connect button on hover
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>
2026-05-25 03:46:31 -04:00
mollusk ccb183219f fix(gui): keep the viewer Paste row a single line, not a full-height block
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>
2026-05-25 03:43:16 -04:00
mollusk d23848decc feat(gui): paste button, Enter-to-connect, version, and clipboard prefill
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>
2026-05-25 03:37:53 -04:00
mollusk 48f5510699 feat(gui): decode the ticket to flag a stale/invalid paste before connecting
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>
2026-05-25 03:26:21 -04:00
mollusk 125e44c033 feat(gui): auto-copy the host ticket on start, with a copied indicator
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>
2026-05-25 03:06:38 -04:00
mollusk 57328f740c fix(output): route tracing to stderr so --output json stdout stays clean
common::output documents the contract: JSON events on stdout, banner + tracing
on stderr, so a parser reading stdout sees only events. But init_tracing relied
on tracing_subscriber::fmt()'s default writer, which is stdout — so every log
line was interleaved into the JSON event stream the --gui front-end parses.

The GUI tolerated it (non-JSON lines are skipped), but two real consequences:
a tracing write could corrupt a JSON event line intermittently, and all
diagnostics landed on stdout where the GUI discards them — leaving its
stderr-tail ring empty, so a failed host/viewer child surfaced no clue in the
window. Pin the fmt writer to stderr. Verified: every stdout line now parses as
JSON; iroh/tracing output appears on stderr.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 02:39:20 -04:00
mollusk 0187bc9bcf feat(viewer): time out the initial connect instead of hanging forever
endpoint.connect() has no built-in deadline, so an offline host, a stale
share code, or an unreachable relay left the viewer spinning silently with
no feedback — surfacing in the GUI as a permanent "Connecting…" with no
error. Wrap the connect in a 15s tokio::time::timeout (matching the host's
online() cap) and race it against ctrl-c, bailing with an actionable
message. The error reaches stderr, so the GUI's ChildProc stderr-tail
path renders it on the viewer screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 02:25:17 -04:00
mollusk 0be92f36a5 feat(gui): host + viewer tabs driving the headless child
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>
2026-05-24 16:32:26 -04:00
mollusk 6f0fd088f6 feat(gui): scaffold egui window behind the gui feature
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>
2026-05-24 16:26:56 -04:00
mollusk e7ded10db8 feat(output): --output json machine-readable event stream
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>
2026-05-24 16:17:38 -04:00
mollusk 6619bc9b0f feat(cli): --host flag for headless hosting
Hosting was only reachable through the interactive dialoguer menu; there
was no way to start a host non-interactively. Add a --host flag that runs
host::run directly (interactive=false), bypassing the menu. Useful for
scripting and required by the upcoming --gui front-end, which drives this
binary as a child process. Guards against --host + ticket (contradictory).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:15:07 -04:00
mollusk 29d8850bc5 feat(quality): log the actual encode resolution at capture spawn
Window size in the viewer is an unreliable proxy for the encoded
resolution (mpv clamps/scales to the screen), making it hard to tell
whether a preset's downscale actually took effect. Log the concrete
decision host-side when capture spawns:

- "downscaling video from=1920x1080 to=1280x720" when scaling,
- "encoding at native resolution" for Source,
- "source already at/below preset height" when no upscale is needed,
- the unknown-dims fallback case too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:34:36 -04:00
mollusk 8044a42f98 fix(quality): scale after videoconvert at exact even WxH
Live medium-quality stream errored with "negotiation problem" on the
host and rendered a squashed, garbled picture in the viewer. Two causes,
both from inserting videoscale before videoconvert with PAR+range caps:

- videoscale was scaling pipewiresrc's raw output directly. The portal
  source's format/memory (e.g. DMABuf) isn't something software videoscale
  negotiates — the original pipeline always fed pipewiresrc through
  videoconvert first. Move videoscale *after* videoconvert so it operates
  on system-memory NV12/I420.
- `pixel-aspect-ratio=1/1` + a width range over-constrained negotiation
  and risked a non-square-PAR / distorted result. Instead compute an exact
  even WxH from the known source dimensions (Wayland: portal size; X11:
  root/window geometry), preserving aspect, and pin it fully in the caps.
  This is also downscale-only now — a source already at/below the target
  height is left native instead of upscaled. Unknown dims (rare X11
  geometry failure) fall back to the height-only + square-pixel + even
  width-range negotiation.

source_dims threaded through pipeline::spawn from both backends. Smoke
test updated to mirror the new ordering (1920x1080 -> 852x480, videoscale
after videoconvert) and still asserts an even sub-source width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:25:05 -04:00
mollusk 7483b9aae8 feat(quality): resolution/quality presets + Auto from pre-flight
Add a host-global quality knob (Discord-style) so the sharer can trade
resolution + bitrate for upload bandwidth. Quality is host-global by
design: one encode pipeline fans out to every viewer, so per-viewer
quality is out of scope (it would kill the broadcast fanout).

- New `--quality source|high|medium|low|auto` (ValueEnum) bundling a
  (max-height, bitrate, fps) tuple per preset; `auto` derives the preset
  from the saved bandwidth pre-flight (safe_mbps / viewer cap), falling
  back to `medium` when unmeasured. Default is auto; the interactive
  Host branch shows a picker when --quality is omitted (mirrors pick_app).
- `--max-height N` raw override; `--bitrate`/`--framerate` changed to
  Option so an explicit flag overrides just that field of the preset
  (precedence rule), leaving the rest of the preset intact.
- host/quality.rs: Preset table + resolve(); pure resolve_auto() split
  from the config read for testability. 5 unit tests lock preset
  pass-through, the Auto ladder, the unmeasured fallback, and override
  precedence.
- pipeline::build_args inserts `videoscale ! video/x-raw,height=N,
  pixel-aspect-ratio=1/1,width=[2,8192,2]` only for non-Source presets.
  PAR 1/1 forces a proportional downscale (without it videoscale keeps
  full width and squashes PAR — no bandwidth win); the even-stepped width
  range + even-rounded height satisfy H.264 4:2:0. EffectiveQuality is
  threaded capture -> wayland/x11 -> pipeline; max_viewers is now sized
  against the effective (post-preset) bitrate.
- Banner gains a quality line (preset label + ≤Np/kbps/fps + provenance).
- deps.rs checks `videoscale`; smoke-pipeline.sh adds a 1080->480
  downscale check asserting an even width below source.
- README: --quality preset table, Auto behavior, host-global note,
  --max-height/--bitrate/--framerate override precedence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:03:14 -04:00
mollusk 45e5d7ef37 feat(cli): remove --mic (microphone is out of scope)
pixelpass is a screen-share tool meant to be paired with a dedicated
voice app (Mumble, TeamSpeak, Discord, …) for two-way talk — it never
mixes a mic. The --mic flag was declared, shown in the host banner, and
documented as working, but was never wired into the gst pipeline (a
no-op). Removed the flag from Cli + HostOpts + into_host_opts, dropped
it from the banner capture summary, and replaced the README's "--mic
mixes the mic" claim with an explicit out-of-scope note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 21:15:45 -04:00
mollusk cd127a9704 feat(host): X11 capture backend + shared pipeline extraction
Extract the display-agnostic encode/mux tail out of wayland.rs into a new
host/pipeline.rs: CaptureHandle + lifecycle, audio routing setup, the gst
arg builder, the spawn, and Serve::bind now live there. Backends supply
only their video-source element args plus a post-spawn hook (Wayland uses
it to close its leaked pipewire fd; X11 passes a no-op). capture.rs
collapses to a thin dispatcher; its CaptureHandle enum is gone.

Add host/x11.rs: ximagesrc (use-damage=false show-pointer=true), whole
root window by default or a single window via --window (xwininfo
click-picker → xid). x11rb reads geometry for an info log, justifying the
previously-vestigial dep. No portal, no fd dance — capture starts
silently when the first viewer connects (the ticket is the access
control). Viewer is display-agnostic and unchanged.

Wire --no-hwencode for real (was a no-op): the shared tail now selects
x264enc(tune=zerolatency,ultrafast)/I420 vs vah264enc/NV12 and switches
the videoconvert target format to match. Applies to both backends.

deps.rs: check_host_binaries now takes &HostOpts and checks shared
elements for both backends, encoder by --no-hwencode, source per backend
(pipewiresrc/ximagesrc), and xwininfo only when X11 + --window. Install
hints added for x264enc, ximagesrc, xwininfo.

Verified: warning-free build; smoke test still passes (tail unchanged);
ximagesrc + both encoder tails produce mpv-decodable H.264 against an
Xwayland root. Interactive cross-machine end-to-end pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:48:50 -04:00
mollusk 0c9d8eb9f9 host: emit relay-only ticket (drop direct IP candidates)
The host ticket embedded every direct IP candidate the endpoint
discovered — on this machine that was 10 addrs, 7 of them useless
Docker-bridge gateways (172.16.0.0/12) plus LAN/public v4/v6. That
bloated the ticket to ~320 chars and leaked local network topology to
whoever received it.

Keep only the endpoint id + relay URL (~140 chars). The relay
coordinates hole-punching to a direct path after connect, so peer
reachability is unchanged; the direct addrs in the ticket only ever
shaved a moment off the first connection attempt, and n0 DNS discovery
already publishes the full addr keyed by id as a backstop.

Await endpoint.online() (15s cap) before building the ticket so the
relay URL is reliably populated; a relay outage degrades to a
possibly-incomplete ticket rather than a hang.

Experimental — isolated on feat/short-ticket pending an end-to-end
cross-machine connect test before merging to main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:17:38 -04:00
mollusk 7fa5d410f9 cli: drop stale "+ ffmpeg" from --help about string
The Wayland path moved from a shelled-out ffmpeg to an in-process
GStreamer pipeline back in the 2026-05-16/18 pivot, but the clap
`about` string still advertised ffmpeg. Now reads "P2P screen sharing
over iroh".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 15:44:02 -04:00
mollusk 25a5b597f7 repair: unload orphan pixelpass_capture_* sinks and paired loopbacks
Replaces the Phase-2 stub. Parses `pactl list short modules` for
`module-null-sink` entries whose `sink_name=pixelpass_capture_<pid>`
names a PID with no /proc/<pid>, and `module-loopback` entries whose
`sink=` names one of those orphan sinks. Unloads loopbacks first, then
sinks (mirrors Routing::shutdown order so PipeWire doesn't leave
zombie links).

Live PIDs — including this process and any other running pixelpass —
are skipped and reported. Same-tab parser is robust to multi-line
{ ... } argument blocks from other modules because continuation lines
never parse as a u32 module ID.

Verified with synthetic orphans against this build:
  - single dead orphan (sink + loopback) → both cleaned, count = 2
  - single live orphan (pid 1) → both preserved, message names the
    live count
  - mixed dead + live → dead pair cleaned, live pair preserved,
    output reports both

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 16:33:33 -04:00
mollusk 54ebe96ca1 host/audio: oscillate loopback on stream lifecycle (session 4 of 4)
Subscribe registry.global_remove so we know when routed stream nodes
vanish; drop them from routed_node_ids and emit LastRoutedStreamGone
on the N→0 transition. Tokio side re-runs `pactl load-module
module-loopback` with the same args as start, restoring the
default-sink monitor mirror so the viewer hears system audio again
instead of going silent when the routed app exits mid-session.

FirstRoutedStream now fires on every 0→N transition (not just the
first), so the pair oscillates cleanly: each app open/close cycle
unloads → re-loads the loopback.

Verified cross-machine 2026-05-22 16:29 EDT — host with Strawberry
picked, laptop viewer over mpv with YouTube playing on host as a
control. Strawberry audible on laptop, YouTube silent (route active).
Quit Strawberry → YouTube became audible (loopback restored).
Reopened Strawberry → routed again, YouTube dropped out (loopback
unloaded). Clean Ctrl+C teardown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 16:29:28 -04:00