Commit Graph
90 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 04bc0a808a perf(friends): fire share-campaign rounds concurrently
run_share tried offline peers one at a time, so a single unreachable
friend's ~10s control-plane connect timeout serialised the whole round
(N offline peers → up to N×10s per round). Spawn each round's sends into
a JoinSet and collect as they finish: a round now takes ~one timeout
regardless of how many friends are offline. Delivery receipts are still
emitted one-per-peer as each ACK lands; the code is shared across tasks
via an Arc instead of re-cloning the payload per peer per round.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:01:42 -04:00
molluskandClaude Opus 4.8 b0fa259187 feat(friends): persist per-friend share preference across launches
The host's "auto-share my code with" picker was session-scoped — an
in-memory exclusion set that reset to share-with-all on every launch.

Move the preference onto the friend itself: a `share: bool` on `Friend`
in friends.toml, `#[serde(default = true)]` so new friends are included
and an older file without the field loads as share-with-all. The picker
now toggles the stored flag and persists immediately (like the other
settings), and `selected_share_targets` filters on it. This drops the
parallel `share_excluded` state and is self-cleaning: removing a friend
takes their preference with them, no stale ids linger.

`upsert`'s update path leaves `share` untouched, so a name/presence
refresh can't reset the user's choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:01:42 -04:00
molluskandClaude Opus 4.8 1a746461b4 feat(friends): push share codes to friends on hosting + receive bell (phase 4)
The payoff phase: on Start-Hosting, auto-push the wrapped share code to the
selected accepted friends, and surface codes friends push us in a bell.

Sending (host side):
- A share-target picker on the host form lists accepted friends as checkboxes;
  selection is stored as the *exclusion* set so the default ships to everyone
  and a friend added mid-session is included automatically.
- When the child reports its ticket, the wrapped code is pushed to the selected
  friends, gated by FriendStore::is_accepted.
- Delivery is online-now + retry-while-hosting: the presence service runs an
  abortable share campaign that retries offline friends every 5s until they're
  reached or hosting stops. The control-plane ACK is the delivered/failed
  signal; each success emits a ShareDelivered receipt.
- The running host screen shows a live "delivered ✓ / offline, retrying" row
  per targeted friend.

Receiving (viewer side):
- The previously-stubbed ShareCode handler now honours codes from accepted
  friends only, records a notice (deduped per friend), and fires a desktop
  notification.
- A top-right bell with a white-on-red badge counts pending notices; its panel
  lets you Watch a code (opens the viewer with it prefilled) or dismiss it.

Presence service refactor: the fire-and-forget Outbound becomes a Command enum
(Send / StartShare / StopShare) and the UI now drains a PresenceEvent enum
(Message / ShareDelivered) over one unified async→sync bridge.

Tests: +2 for the share-target selection rule (43 gui pass). clippy + fmt clean
on both feature sets; smoke-launch shows the control endpoint online, no panic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:01:19 -04:00
molluskandClaude Opus 4.8 9e839ca452 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>
2026-05-30 16:42:14 -04:00
molluskandClaude Opus 4.8 f5d0333366 feat(friends): always-on control plane for the presence service (phase 2)
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>
2026-05-30 16:25:45 -04:00
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 b1d73caedf docs(readme): document the GUI theme system
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:51:04 -04:00
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 7e470fb2c5 docs(readme): document --relay / PIXELPASS_RELAY
Add a Relay section covering the flag and env-var forms, precedence,
the GUI-child forwarding, and the same-relay-on-both-ends requirement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:23:25 -04:00
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 69ddc58133 feat(packaging): honour CARGO_TARGET_DIR + document distrobox build
build-appimage.sh now reads the binary from CARGO_TARGET_DIR when set, so a
broad-compat build inside an old-glibc distrobox can use an isolated target
dir without clobbering the host's. README documents the Ubuntu 24.04
distrobox recipe and why older bases don't work (the pipewire crate needs
PW >= ~1.0 headers; and a PipeWire/portal app can't run on ancient distros
anyway). Resulting baseline: glibc 2.39 (the only 2.39 symbols are weak
pidfd refs from Rust std; everything else is <= 2.35).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 16:29:05 -04:00
molluskandClaude Opus 4.8 09a07f5303 feat(packaging): add a thin AppImage build
build-appimage.sh produces pixelpass-<version>-x86_64.AppImage via
linuxdeploy. PixelPass links almost nothing (only libpipewire, which is
excludelisted) and shells out to gst-launch-1.0/pactl/a player on the host
PATH, while its GUI graphics libs are dlopen'd and also excludelisted — so
the AppImage bundles just the binary, AppRun, desktop entry, and icon
(~13 MB, zero bundled libs). The no-sandbox model lets the bundled binary
spawn the host's tools, which is why AppImage fits this orchestrator better
than Flatpak.

AppRun opens --gui when launched with no args and no controlling terminal
(file manager / .desktop), and passes through otherwise so the CLI,
interactive menu, and viewer all work. README documents the host-deps
contract + the glibc-baseline and VAAPI caveats.

Verified: builds to 13 MB; --version/--help work; the GUI launches cleanly
on a live Wayland session via both --gui and the no-tty AppRun path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 16:10:02 -04:00
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.8 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 6f1ccf3923 gui: ship SIL OFL 1.1 license for bundled Noto Sans
Required by the OFL for redistribution. Installs alongside the
existing MIT and Apache-2.0 license files in the Arch package.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 04:26:47 -04:00
mollusk 8cd2d63a87 gui: Increase default font size and use Noto Sans 2026-05-27 03:23:20 -04:00
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 6c275faf28 feat(packaging): add MIT/Apache-2.0 license files
Add LICENSE-MIT and LICENSE-APACHE (the dual license already declared in
Cargo.toml, previously absent) and install both into the package. Retarget
the PKGBUILD git source to main now that the packaging branch has merged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 04:55:18 -04:00
molluskandClaude Opus 4.7 2edd7f0fa8 chore(packaging): gitignore makepkg build artifacts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 04:47:53 -04:00
molluskandClaude Opus 4.7 f4a4dd37c9 feat(packaging): add Arch PKGBUILD (local versioned build)
Builds pixelpass 0.1.0 with --features gui from the local repo and
installs the binary, .desktop launcher, scalable icon, and README.
Runtime deps mapped from src/common/deps.rs (GStreamer pipeline +
pactl); viewers and alternate encoders are optdepends.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 04:43:27 -04:00
molluskandClaude Opus 4.7 56d0d6c2e2 feat(packaging): add app icon and desktop entry
Scalable SVG app icon (pixel-stream motif, indigo->violet ground) plus
a freedesktop .desktop launcher for the --gui front-end, groundwork for
the first Arch package.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 04:38:49 -04:00
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 90e0dc8621 docs: document --gui front-end and the gui build feature
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:34:23 -04:00
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 8674f907f2 docs: sync README status with shipped audio + repair work
Per-app audio routing (--app), mic mixing (--mic), and --repair all
landed in recent commits but the README still listed the first and last
as stubs. Move them to Working, drop them from "Not yet working" (X11
capture is now the only remaining stub), and add an Audio section
documenting --app/--mic/--repair.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 15:37:23 -04:00
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 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
molluskandClaude Opus 4.7 a144665f41 host/audio: per-stream routing via libpipewire (session 3 of 4)
When opts.app is set, a dedicated OS thread runs a libpipewire
MainLoop, subscribes to the registry, and writes target.object to
the "default" metadata so WirePlumber reroutes matching streams to
our per-PID null-sink. Activation is now opts.app.is_some() OR the
existing PIXELPASS_AUDIO_VIA_NULL_SINK env var (kept for
no-filter dogfooding).

Threading: tokio side spawns a std::thread; the two sides bridge via
pipewire::channel for cmd→thread (Shutdown) and tokio::sync::mpsc
for event→tokio (FirstRoutedStream). Cross-thread quit goes through
the libpipewire channel so MainLoop is only mutated from its own
thread. Shutdown clears target.object on every routed stream before
quitting so WirePlumber doesn't log orphans.

Routing decisions:
- Filter is case-insensitive equality on application.name (predictable;
  no surprise matches from substring).
- target.object is written as Spa:Id with the sink's object.serial.
- Default-sink loopback stays loaded until the first stream is
  actually routed — avoids viewer silence if the user picks an app
  that isn't producing sound yet. On first route, the event task
  takes() the loopback module ID and unloads it.

Session 2 picker explainer + (app pick saved: ...) banner softening
both removed; banner is back to plain app-audio=NAME.

Verified end-to-end cross-machine: desktop host with Strawberry
selected, laptop viewer over mpv. Strawberry audible on the laptop;
YouTube playback started on the desktop was NOT audible on the
laptop. Routing isolates the filtered app.

Session 4 still open: recreate loopback when the last filtered stream
disappears (avoid silence), handle app-disappears-mid-session,
multi-instance, --repair coupling for orphan sink cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:56:44 -04:00
molluskandClaude Opus 4.7 339a9d49e4 host/audio: app enumeration + interactive picker (session 2 of 4)
list_playing_apps() shells out to `pactl -f json list sink-inputs`,
parses with serde_json, dedupes by application.name (BTreeMap for
stable ordering), returns Vec<App { name, stream_count }>.

Picker fires in interactive::run after preflight, before host::run.
Bypassed when --app NAME is on the CLI. Shows the apps with a
"per-app routing isn't live yet" explainer so users aren't surprised
that audio still captures system-wide. Empty-list path shows the
default + a "start your app first" hint so the feature stays
discoverable.

Banner softened to `system-audio (app pick saved: <name>)` when
opts.app is set — keeps the choice visible without lying about what
gets captured. Routing activation still gated on the
PIXELPASS_AUDIO_VIA_NULL_SINK env var (session 1's locked decision
#2); --app flips to that activation in session 3 once per-stream
filtering exists.

Verified end-to-end interactively: Strawberry shows up in the picker
during music playback, both default and app-pick paths advance into
the portal handshake, banner matches choice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:32:00 -04:00
molluskandClaude Opus 4.7 8d32ded412 host/audio: per-PID PipeWire null-sink + loopback scaffolding
Session 1 of the per-app audio routing feature. Adds host/audio.rs
with a Routing struct that owns the lifecycle of two pactl-loaded
modules: a per-PID null-sink (pixelpass_capture_<pid>) and a loopback
mirroring @DEFAULT_SINK@.monitor into it at 20ms latency. Activated
by PIXELPASS_AUDIO_VIA_NULL_SINK=1 — kept hidden behind an env var
because without per-stream filtering (session 3) the user-facing
behavior of --app foo would be identical to no flag, which would
mislead users about what the flag does.

When the env var is set, wayland::start substitutes the gst pulsesrc
device from {DEFAULT_SINK}.monitor to pixelpass_capture_<pid>.monitor;
audio still works end-to-end via the loopback. CaptureHandle owns the
Routing alongside gst and serve; teardown order is gst → audio → serve
so streams unlink from the null-sink before the sink is destroyed.

Lifecycle is via pactl shell-outs rather than pipewire-rs. Null-sink
+ loopback are one-shot graph mutations with no event subscription;
the libpipewire route would mean dragging a MainLoop thread in for no
benefit until session 3 needs stream events.

Known cosmetic: the null-sink appears in Plasma's audio mixer as a
user-facing volume slider. Pactl's sink_properties= quoting is fiddly
enough that the device.hidden=true fix is parked for a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:11:48 -04:00
molluskandClaude Opus 4.7 9625511c65 cleanup: demote clean-disconnect warn, drop dead --low-latency flag
handle_peer's `bridge ended with error: ...` log fired at WARN every
time a viewer cleanly closed — but bridge can only end three ways
(peer-close, local-socket-close, cancellation), none of which are real
errors. Collapsed to INFO for both Ok and Err arms; the message itself
still carries any error detail.

Also removed the `--low-latency` CLI flag and its HostOpts field. It
was a placeholder for an unimplemented Phase-2/3 SRT transport, never
read anywhere, and was generating a persistent dead_code warning. If
SRT ever happens, the flag can come back fresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 03:50:20 -04:00
molluskandClaude Opus 4.7 e1a018fdf7 host/serve: extract HTTP fanout from wayland.rs
The broadcast fanout, supervisor-facing listener bind, accept loop, and
per-viewer drain were all sitting inside host/wayland.rs even though
none of it is Wayland-specific. Move them to host/serve.rs so the X11
backend can share the same serving layer with a one-line constructor
call instead of copy-pasting (and drifting on) the fanout code.

No behavior change. Wayland's CaptureHandle now wraps a serve::Serve
instead of owning the listener/reader/server fields directly; gst
pipeline construction is unchanged. connect_to_capture moves alongside
Serve since it pairs with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 02:32:35 -04:00
molluskandClaude Opus 4.7 f939441e31 README: document multi-viewer + bandwidth pre-flight
Updates the status section to move multi-viewer out of "not yet
working", adds a Configuration section pointing at the new TOML config
at ~/.config/pixelpass/config.toml, and a Multi-viewer section
covering the lazy-sticky lifecycle, the --max-viewers cap, the
bandwidth-bitrate tradeoff, and how to fit more viewers by dropping
--bitrate. Known-limitations section gains "late joiners see ~2 s of
garbage" (expected behavior) and drops the now-stale "single viewer
per host" line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 17:00:58 -04:00
molluskandClaude Opus 4.7 153febe078 pre-flight: bandwidth test + persistent config
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>
2026-05-21 16:55:11 -04:00
molluskandClaude Opus 4.7 ffe5a90686 multi-viewer: broadcast fanout + supervisor lifecycle
One gst capture pipeline now fans out to N concurrent viewers via a
tokio::sync::broadcast<Arc<Vec<u8>>>. The HTTP listener accepts forever;
each accepted connection spawns a sender task draining its own
broadcast::Receiver. Slow consumers see Lagged and skip ahead — MPEG-TS
resyncs at the next keyframe.

Host runtime is now lazy + sticky: a supervisor task owns the capture
handle and viewer count. First viewer triggers capture::spawn; last
viewer triggers shutdown. Subsequent reconnects re-trigger the portal
dialog as expected. --max-viewers (default 2) caps concurrent viewers;
additional connections get a "host is full" refusal and are dropped.

Banner updated to reflect the new lifecycle and viewer cap.

NOT YET RUNTIME-VERIFIED. cargo build is clean and the pipeline-level
smoke test still passes, but the multi-viewer behavior (cap enforcement,
lazy-sticky restart, concurrent fanout) requires manual end-to-end
testing with the portal dialog + multiple mpv instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:11:43 -04:00
molluskandClaude Opus 4.7 74b4101d4f vlc-plugin-ffmpeg: extend docs + runtime check
The previous vlc-plugin-dvb diagnosis was incomplete. On a laptop with
only vlc-plugin-dvb installed, VLC reads the MPEG-TS container, sees
the H.264 stream type in the PMT, then errors "Codec h264 ... is not
supported" because libavcodec_plugin.so is also a split package and
also wasn't pulled in by the base `vlc` install.

Installing vlc-plugin-ffmpeg (which pulls ffmpeg4.4 as a compat dep)
on the laptop made VLC play pixelpass cleanly via Intel iHD hardware
decode.

- README: list both plugin packages under requirements; rewrite the
  known-limitations line.
- interactive.rs: extend the launch-time check to also probe for
  libavcodec_plugin.so; combine both into one warning that lists
  every missing piece and the single pacman invocation to fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 06:21:32 -04:00
molluskandClaude Opus 4.7 6e4d30bfa9 vlc-plugin-dvb: document and warn at launch
VLC's MPEG-TS demuxer (libts_plugin.so) ships in a separate package on
Arch / CachyOS (vlc-plugin-dvb). Without it, VLC silently falls back
to the PS demuxer and misidentifies our H.264 stream — the symptom is
a green screen. mpv doesn't share this dependency.

- README: list vlc-plugin-dvb under requirements, replace the
  "green screen, not yet diagnosed" gotcha with the diagnosis.
- interactive.rs: when the user picks VLC, check for
  /usr/lib/vlc/plugins/demux/libts_plugin.so and print a warning to
  stderr if it's missing. Soft warning, not a hard error — VLC still
  spawns so the user can confirm the symptom for themselves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 05:28:19 -04:00
molluskandClaude Opus 4.7 15766834f1 Revert "viewer HTTP: Content-Type application/octet-stream, not video/mp2t"
This reverts commit 0a253bd919.

The Content-Type change was a misdiagnosis. The real cause of VLC's
"no demux modules matched" was a missing `vlc-plugin-dvb` package on
the test machine — Arch/CachyOS ship the MPEG-TS demuxer plugin
(`libts_plugin.so`) in a separate package from `vlc`. Without it, VLC
falls through to the PS demuxer and misidentifies the H.264 stream.
With the package installed, `video/mp2t` opens cleanly.

`video/mp2t` is the correct Content-Type for an MPEG-TS stream and is
what we should be sending. Documentation of the package requirement
and a runtime check follow in a separate commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 05:26:53 -04:00
molluskandClaude Opus 4.7 0a253bd919 viewer HTTP: Content-Type application/octet-stream, not video/mp2t
VLC parses Content-Type before invoking the demuxer chain. With
video/mp2t it commits to demux="ts" by MIME alone, bypassing
byte-probing; when the ts demuxer's Open fails on the live HTTP stream
("no demux modules matched"), the input never opens. mpv probes
regardless of Content-Type.

Reproduced deterministically with a Python shim that mimics our
response headers byte-for-byte: only the Content-Type matters.
Changing it to application/octet-stream (or any non-video MIME, or
omitting the header) makes VLC fall back to byte-probing, which
finds the TS sync pattern and opens cleanly. mpv unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:30:46 -04:00
molluskandClaude Opus 4.7 3aa8d73ea0 Cargo.toml: drop ffmpeg from package description
ffmpeg was removed from the Wayland path on 2026-05-16 (commit 7b8b6bc).
The description was stale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:07:48 -04:00
molluskandClaude Opus 4.7 8619df10d5 Add README
Covers v0.1 status, quick-start (interactive + headless), system
deps, build, architecture diagram, design rationale, and known
limitations. No README existed before — this fills the gap now that
v0.1 is verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:04:36 -04:00
mollusk 858540c3c5 viewer: default mpv to --hwdec=auto
Software H.264 decode at 1080p struggled on a low-power viewer (Intel
UHD 620 laptop on battery / power-save governor), surfacing as choppy
motion and A-V drift even though the host encode side was clean. mpv's
auto hwdec picks vaapi/nvdec/etc. when available and silently falls
back to software when not, so the default is strictly safer.

Applied to both the headless banner recipe and the interactive
player-picker spawn args.
2026-05-18 17:10:36 -04:00
mollusk bb437a73cf interactive: Host/View entry menu, clipboard copy, player picker
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.
2026-05-18 15:46:57 -04:00
molluskandClaude Opus 4.7 4cab9f6b20 audio: capture default sink's monitor, not default source
`pulsesrc` with no `device=` reads PulseAudio's default source —
which is the user's microphone, not system audio output. The stream
was technically working but the laptop was hearing the desktop's
mic (or silence on systems without one) instead of system audio.

At host startup, shell out to `pactl get-default-sink` to discover
the current default sink, then pass `device=<sink>.monitor` to
pulsesrc. Resolving at session-start covers users who switch outputs
(speakers vs headset vs HDMI) between sessions. pactl added to the
host's required-binary list.

Verified cross-machine: audio came through clearly with the prior
~1s latency floor preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:42:42 -04:00
molluskandClaude Opus 4.7 7983701b03 Fix latency drift: cap framerate + tune mpv buffers
Symptom: cross-machine streams drifted ~6-9 seconds per minute. mpv
showed continuous "Audio device underrun detected" with video packets
piling up in its demuxer queue while audio queue stayed at 0.

Root causes:
- pipewiresrc captured at the host monitor's refresh rate (180Hz),
  not at the configured framerate. The encoder + mux produced 6x more
  frames per wallclock second than mpv could consume at realtime,
  burying audio packet density in mpv's demuxer queue.
- `--profile=low-latency` sets `audio-buffer=0`, which is too
  aggressive — any sub-millisecond network jitter immediately
  starves the audio device. Underruns slowed mpv's audio clock, and
  with `video-sync=audio` (also from low-latency profile) video
  followed.

Fixes:
- `videorate ! video/x-raw,framerate=<fps>/1` after pipewiresrc to
  cap input to the requested rate deterministically.
- mpv command grows `--audio-buffer=0.2 --demuxer-max-bytes=2M
  --demuxer-readahead-secs=0.5`: small audio buffer to absorb network
  jitter, demuxer cap to prevent runaway buildup.

A leaky-queues attempt landed and was reverted in the same commit —
it removed backpressure without addressing the root cause and made
things worse.

Verified cross-machine 6m51s: drift held at ~1s floor, zero audio
underruns, perfect A-V sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:25:17 -04:00
molluskandClaude Opus 4.7 d3bff65add tunnel: keep data direction alive when reverse direction EOFs
The old tokio::select! tore down the whole bridge when EITHER
direction's io::copy finished. For a one-way streaming workload the
reverse direction carries only the initial HTTP GET; once mpv stops
sending and the read half EOFs, the data direction got killed mid-
stream and the host logged "bridge closed cleanly" while the user's
video disappeared.

Spawn the reverse direction as a detached task and `.await` only
the data direction. When the data direction ends naturally, abort
the reverse task. The function gains Send + 'static bounds on T,
which TcpStream satisfies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:30:04 -04:00
molluskandClaude Opus 4.7 7b8b6bcd0c Wayland: VAAPI H.264 + in-process HTTP server, drop ffmpeg
The previous ffmpeg-as-HTTP-server pipeline shape held back two
improvements at once. ffmpeg as the runtime server lost a one-shot
`-listen 1` accept to a probe-and-discard health check, and forced
us to size analyze/probe budgets carefully so ffmpeg would serve
before our deadline. Replacing it with a small tokio task that
accepts once, drains the HTTP request, writes a fixed 200 OK, then
`tokio::io::copy`s gst stdout to the socket removes all of that.

VAAPI H.264 (vah264enc) drops CPU encode from ~50% of a core to
single-digit %. An earlier attempt at vaav1enc had to be abandoned:
libavformat cannot demux AV1-in-MPEG-TS with the custom mapping
even with a 20MB probe budget — mpv reports video=eof. H.264 keeps
the hardware win on the well-trodden demuxer path.

scripts/smoke-pipeline.sh mirrors the runtime pipeline with
videotestsrc/audiotestsrc into a file and asserts that mpv reports
`video=playing` (not video=eof). The naive --frames=10 check was
a false positive when no video stream is recognized; the verbose
grep is the real gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:29:56 -04:00
mollusk 75a01a361e Working Wayland end-to-end: gst owns the pipeline, ffmpeg just serves
Moves the full capture+encode+mux pipeline into gst-launch, leaving
ffmpeg as a thin HTTP server. Verified end-to-end on KDE Plasma 6
Wayland: screencast portal → mpv mirror-tunnel rendering in real time.

Pipeline:
  pipewiresrc(do-timestamp) → videoconvert → x264enc (zerolatency
    ultrafast) → h264parse(config-interval=-1) → byte-stream caps →
    mpegtsmux ← (aacparse ← avenc_aac ← audioconvert ← pulsesrc) →
    fdsink fd=1
  ffmpeg -fflags nobuffer+discardcorrupt+genpts -flags low_delay
    -analyzeduration 0 -probesize 32 -f mpegts -i pipe:0 -c copy
    -f mpegts -listen 1 http://127.0.0.1:N

Why each piece is load-bearing (do not relitigate without cause):

- x264enc + h264parse + byte-stream caps: raw video over a pipe hits
  stride/format negotiation problems (green screens with mis-aligned
  rows). Encoding inside gst sidesteps that entirely.
- mpegtsmux inside gst: H.264 Annex B carries no timestamps. Without
  a container, ffmpeg sees "Timestamps are unset" and downstream
  muxing breaks. mpegts in gst preserves pipewiresrc's clock.
- byte-stream + alignment=au caps: h264parse defaults to AVC format
  (length-prefixed NALUs) for some downstreams; ffmpeg's mpegts
  demuxer needs Annex B start codes.
- audio in gst (pulsesrc + avenc_aac): keeping ffmpeg as a pure
  passthrough (`-c copy`) avoids ffmpeg's audio-input dependency
  delaying HTTP serving until both inputs are ready.
- `-analyzeduration 0 -probesize 32`: stop ffmpeg from buffering 5MB
  / 5s of input before deciding it understands the stream.
- Also fixes a separate one-shot bug from earlier: the previous
  health-probe in wait_for_listener consumed ffmpeg's single
  `-listen 1` accept slot, so the actual bridge connect hit
  Connection refused. Replaced with connect_to_capture which
  returns the bridge socket.

Adds dep checks for pipewiresrc, x264enc, h264parse, mpegtsmux,
pulsesrc, avenc_aac, aacparse with per-distro install hints.

Known gap: VLC currently shows a green screen against the stream
even though mpv works fine. Likely VLC-specific demuxer/latency
settings, not a pipeline correctness issue — to investigate as a
follow-up. mpv is the recommended client either way.
2026-05-15 16:43:23 -04:00
mollusk c028e39aba Detect missing pipewiresrc element before host startup
`gst-launch-1.0` and `pipewiresrc` ship in separate packages on most
distros (Arch: `gst-plugin-pipewire`, Debian: `gstreamer1.0-pipewire`,
Fedora/openSUSE: `pipewire-gstreamer`). Having the gst binary present
was no guarantee the Wayland capture pipeline would actually work —
without the plugin gst would bail at runtime with `no element
"pipewiresrc"`, which then cascaded into ffmpeg seeing EOF on its
stdin and exiting before its HTTP listener bound, then the host
hitting "Connection refused" against its own port. Confusing.

Now `deps::check_host_binaries` probes `gst-inspect-1.0 --exists
pipewiresrc` on Wayland and fails early with a per-distro install
hint pointing at the right package.
2026-05-15 15:34:28 -04:00
mollusk 3a551c2287 Wayland capture: ashpd portal → gst-launch → ffmpeg → MPEG-TS
Implements the Wayland host pipeline from plan §4.5:

  ashpd ScreenCast portal
    -> CreateSession + SelectSources + Start + OpenPipeWireRemote
    -> (pipewire fd, node_id, width, height)
  gst-launch-1.0 pipewiresrc fd=N path=NODE_ID ! videoconvert
    ! video/x-raw,format=NV12 ! fdsink fd=1
  ffmpeg
    -f rawvideo -pix_fmt nv12 -video_size WxH -i pipe:0
    -f pulse -i default
    -c:v libx264 -preset ultrafast -tune zerolatency
    -c:a aac -f mpegts -listen 1 http://127.0.0.1:<rand>

Phase 1 ships software x264 per plan §7; VAAPI is Phase 2.

src/host/wayland.rs is the new module. capture.rs becomes a thin
dispatcher with a CaptureHandle enum (Wayland today, X11 next).
host/mod.rs swaps the 150ms sleep for a poll-until-listener-ready
helper, and calls handle.shutdown().await for an orderly SIGTERM /
1s grace / SIGKILL teardown. The Drop impl is the panic backstop.

The pipewire fd handoff clears CLOEXEC before gst-launch spawn and
closes the parent's copy of the raw fd after the child has it.

Also deletes the empty src/host/tunnel.rs and src/viewer/tunnel.rs
placeholder files — the generic bridge in common/tunnel.rs is doing
the work, and there's no host- or viewer-specific tunnel concern
worth a module yet.
2026-05-15 15:22:35 -04:00
mollusk 6ad92081aa Phase 1 foundation: CLI, iroh tunnel, lazy capture wiring
Scaffolding for PixelPass per ~/Documents/p2p-screenshare-plan.md §7
"Phase 1 — MVP". The full QUIC tunnel handshake works end-to-end
(verified locally: host generates ticket, viewer dials through iroh's
relay, open_bi succeeds, lazy capture is wired correctly).

What's implemented:

- Cargo project with deps locked: iroh 1.0.0-rc.0, iroh-tickets,
  tokio, clap, ashpd, pipewire-rs, x11rb, ashpd, anyhow, thiserror,
  tracing, nix, directories, uuid.
- src/cli.rs: complete clap surface per plan §6 (--window, --app,
  --mic, --display-server, --bitrate, --framerate, --no-hwencode,
  --low-latency, --port, --verbose, --repair).
- Mode dispatch in main.rs: EndpointTicket::from_str is the
  authoritative check; no regex / heuristics.
- common/display.rs: WAYLAND_DISPLAY → DISPLAY → XDG_SESSION_TYPE
  precedence with --display-server override.
- common/deps.rs: per-distro install hints (pacman/apt/dnf/zypper)
  parsing /etc/os-release.
- common/alpn.rs: ALPN = b"pixelpass/0".
- common/tunnel.rs: generic bidirectional bridge between an iroh
  bi-stream and any AsyncRead+AsyncWrite (typically a TCP socket).
- common/signal.rs: ctrl-c -> CancellationToken; second ctrl-c hard
  exit.
- host/mod.rs: build Endpoint, generate ticket, print banner, await
  first peer (lazy — no ffmpeg until peer connects), accept_bi,
  spawn capture, bridge to localhost ffmpeg HTTP listener.
- host/capture.rs: stub returning Phase-2 error; the place X11
  x11grab and Wayland ashpd+gst pipelines will land.
- viewer/mod.rs: Endpoint, connect with ALPN, open_bi, TcpListener
  on 127.0.0.1, print copy-ready mpv/vlc commands, bridge.
- repair.rs: stub for --repair PipeWire scan.

iroh 1.0-rc renamed Node* -> Endpoint* and moved EndpointTicket into
a sibling crate (iroh-tickets); no design impact. Plan still locked.
2026-05-15 15:13:28 -04:00