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