Author SHA1 Message Date
mollusk 2e39251189 On feat/gemini-branch-ux-polish: gemini ux-polish WIP: viewer-list/kick/notifications/tray (review baseline) 2026-05-25 15:20:10 -04:00
mollusk c2d04b35ba index on feat/gemini-branch-ux-polish: e8f86b0 feat(gui): focus the viewer code field when the View screen opens 2026-05-25 15:20:10 -04:00
mollusk b4be8deb46 untracked files on feat/gemini-branch-ux-polish: e8f86b0 feat(gui): focus the viewer code field when the View screen opens 2026-05-25 15:20:10 -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
19 changed files with 5094 additions and 485 deletions
Generated
+2636 -181
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -33,8 +33,17 @@ arboard = { version = "3", default-features = false, features = ["wayland-data-c
ureq = { version = "3", default-features = false, features = ["rustls"] } ureq = { version = "3", default-features = false, features = ["rustls"] }
toml = "1" toml = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
eframe = { version = "0.34.2", default-features = false, features = ["glow", "default_fonts", "wayland", "x11"], optional = true }
tray-icon = { version = "0.24.0", optional = true }
notify-rust = { version = "4.17.0", optional = true }
gtk = { version = "0.18.2", optional = true }
[profile.release] [profile.release]
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
strip = "symbols" strip = "symbols"
[features]
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
# build never pulls the GUI toolkit tree.
gui = ["dep:eframe", "dep:tray-icon", "dep:notify-rust", "dep:gtk"]
+104 -20
View File
@@ -16,10 +16,12 @@ without spinning up a Discord call or fighting with NAT.
Working: Working:
- Wayland capture via the screencast portal (KDE Plasma 6 confirmed; other - Wayland capture via the screencast portal (KDE Plasma 6 confirmed; other
Wayland compositors with the portal should work but are untested) Wayland compositors with the portal should work but are untested)
- X11 capture via `ximagesrc` (whole screen, or a single window with
`--window`); selected automatically, or forced with `--display-server x11`
- VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable - VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable
GPUs should work) GPUs should work), with a software x264 fallback via `--no-hwencode`
- Audio capture of the default sink's monitor, with optional per-app - Audio capture of the default sink's monitor, with optional per-app
routing (`--app <name>`) and microphone mixing (`--mic`) routing (`--app <name>`)
- `--repair` cleanup of orphaned PipeWire state left by a crashed host - `--repair` cleanup of orphaned PipeWire state left by a crashed host
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified - iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker - Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
@@ -29,9 +31,14 @@ Working:
- First-run upstream bandwidth pre-flight, persisted to - First-run upstream bandwidth pre-flight, persisted to
`~/.config/pixelpass/config.toml` and used to auto-size the default `~/.config/pixelpass/config.toml` and used to auto-size the default
viewer cap viewer cap
- Quality presets (`--quality source|high|medium|low|auto`) that trade
resolution + bitrate for upload bandwidth, plus an `Auto` mode that
derives quality from the bandwidth pre-flight
Not yet working: Not yet built (deferred, not blocking):
- X11 capture (stubbed, returns an error — Phase 2 follow-up) - Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the
whole root canvas; single-monitor cropping needs xrandr region coords
- `use-damage=true` CPU optimization for the X11 capture path
## Quick start ## Quick start
@@ -66,9 +73,27 @@ pixelpass <ticket>
# then run the printed mpv command in another terminal # then run the printed mpv command in another terminal
``` ```
### Graphical (optional)
A small window front-end is available in builds compiled with the `gui`
feature (see [Build](#build)):
```sh
pixelpass --gui
```
Host: pick quality / max-viewers / options, click **Start hosting**, and the
share code appears with a copy button alongside a live viewer count. View:
paste a code, pick mpv or VLC, click **Connect** and the player launches.
The window is a thin driver — it runs the same headless `pixelpass` as a
child process and reads its event stream, so the GUI is purely additive and
the capture machinery is untouched by it. On a build without the feature,
`--gui` prints a hint to rebuild with it.
## Requirements ## Requirements
- Linux (Wayland session for now; X11 stubbed) - Linux (Wayland or X11; the backend is autodetected)
- A VAAPI-capable GPU and the right driver: - A VAAPI-capable GPU and the right driver:
- AMD: `libva-mesa-driver` - AMD: `libva-mesa-driver`
- Intel: `intel-media-driver` (modern iGPUs) or `intel-vaapi-driver` (older) - Intel: `intel-media-driver` (modern iGPUs) or `intel-vaapi-driver` (older)
@@ -112,15 +137,23 @@ cargo build --release
`rustc` 1.95+ / edition 2024. `rustc` 1.95+ / edition 2024.
The optional graphical front-end (`pixelpass --gui`) is behind a default-off
cargo feature so the headless build stays lean (it pulls the egui/eframe
windowing stack). Build it with:
```sh
cargo build --release --features gui
```
## How it works ## How it works
``` ```
Host Viewer Host Viewer
──── ────── ──── ──────
Wayland portal (ashpd) ──> PipeWire fd Wayland portal (ashpd) ──> PipeWire fd ─┐ (X11: ximagesrc, no portal)
gst-launch: pipewiresrc -> videorate -> vah264enc -> gst-launch: <source> -> videorate -> vah264enc/x264enc ->
h264parse -> mpegtsmux h264parse -> mpegtsmux
(audio: pulsesrc <sink>.monitor -> (audio: pulsesrc <sink>.monitor ->
avenc_aac -> aacparse ─┘) avenc_aac -> aacparse ─┘)
@@ -142,7 +175,7 @@ cargo build --release
The viewer's player connects to a localhost HTTP server, which is The viewer's player connects to a localhost HTTP server, which is
just one end of the iroh tunnel. The host's HTTP server sits on the just one end of the iroh tunnel. The host's HTTP server sits on the
other end and streams GStreamer's stdout (an MPEG-TS containing other end and streams GStreamer's stdout (an MPEG-TS containing
hardware-encoded H.264 + AAC) through with no demux or remux. H.264 + AAC) through with no demux or remux.
iroh handles NAT traversal: direct UDP if hole-punching succeeds, iroh handles NAT traversal: direct UDP if hole-punching succeeds,
relay path otherwise. Both have been verified end-to-end. relay path otherwise. Both have been verified end-to-end.
@@ -186,21 +219,42 @@ measured_at = "2026-05-21T20:41:16Z"
## Audio ## Audio
By default pixelpass captures the default sink's monitor — the viewer By default pixelpass captures the default sink's monitor — the viewer
hears whatever the host hears. Two flags adjust this: hears whatever the host hears. `--app <name>` narrows that to a single
application: pixelpass creates a per-PID null-sink and uses libpipewire to
reroute matching `Stream/Output/Audio` nodes (by `application.name`) into
it, so the viewer hears just that app instead of the whole desktop. In the
interactive menu you can pick the app from a list of what's currently
playing.
- `--app <name>` routes only a single application's audio. pixelpass Microphone capture is intentionally out of scope — pixelpass is a
creates a per-PID null-sink and uses libpipewire to reroute matching screen-share tool meant to be paired with a dedicated voice app (Mumble,
`Stream/Output/Audio` nodes (by `application.name`) into it, so the TeamSpeak, Discord, …) for two-way talk.
viewer hears just that app instead of the whole desktop. In the
interactive menu you can pick the app from a list of what's currently
playing.
- `--mic` mixes the default microphone source into the stream alongside
system audio.
If a host crashes mid-session it can leave orphaned `pixelpass_capture_*` If a host crashes mid-session it can leave orphaned `pixelpass_capture_*`
null-sinks and their paired loopbacks loaded in PipeWire. Run null-sinks and their paired loopbacks loaded in PipeWire. Run
`pixelpass --repair` to unload them and exit. `pixelpass --repair` to unload them and exit.
## Display server
The capture backend is autodetected from the environment
(`WAYLAND_DISPLAY` → Wayland, else `DISPLAY` → X11, else
`XDG_SESSION_TYPE`). Override it with `--display-server wayland|x11` — for
example to force the X11 path while running inside a Wayland session (an
Xwayland or Xephyr `DISPLAY`).
- **Wayland** goes through the screencast portal: a "Share Screen?" dialog
appears when the first viewer connects, and you pick the monitor (or
window, with `--window`) there.
- **X11** uses `ximagesrc` and starts silently when the first viewer
connects — the ticket is the access control, there's no portal gate.
`--window` runs an `xwininfo` picker (click the window you want to
share); without it the whole root window is captured.
Encoding is hardware VAAPI (`vah264enc`) by default. `--no-hwencode`
switches to software x264 (`x264enc tune=zerolatency`) for hosts without a
working VAAPI H.264 entrypoint — higher CPU, no GPU needed. This applies
to both backends.
## Multi-viewer ## Multi-viewer
One gst capture pipeline fans out to N concurrent viewers via a One gst capture pipeline fans out to N concurrent viewers via a
@@ -217,7 +271,37 @@ message and the host keeps running.
For more viewers, drop the per-viewer bitrate: e.g. `pixelpass For more viewers, drop the per-viewer bitrate: e.g. `pixelpass
--bitrate 2500 --max-viewers 4` fits four 2.5 Mbps streams in roughly --bitrate 2500 --max-viewers 4` fits four 2.5 Mbps streams in roughly
12 Mbps of upstream. 12 Mbps of upstream. The `--quality` presets below are the friendlier
way to do the same thing.
## Quality
`--quality <preset>` bundles a max video height, bitrate, and framerate —
resolution is a quality-per-bitrate knob, so the three only make sense
together. Quality is **host-global**: one encode pipeline fans out to every
viewer, so the sharer picks one quality for everyone (per-viewer quality
would need per-viewer encodes, which kills the fanout).
| Preset | Max height | Bitrate | fps |
|----------|-------------------|-----------|-----|
| `source` | native (no scale) | 6000 kbps | 30 |
| `high` | 1080p | 4000 kbps | 30 |
| `medium` | 720p | 2500 kbps | 30 |
| `low` | 480p | 1000 kbps | 30 |
| `auto` | derived (below) | derived | 30 |
`auto` (the default) picks the highest preset whose bitrate fits your
measured safe upstream divided by the viewer cap — so quality is sized for
the worst case, since it's baked in when capture starts and can't drop when
a second viewer joins. With no `--max-viewers`, it sizes for a single
viewer. If there's no bandwidth measurement yet, `auto` falls back to
`medium` (run `pixelpass --reconfigure` to measure). In the interactive
menu, omitting `--quality` shows a picker instead of assuming `auto`.
Downscaling preserves the source aspect ratio with square pixels and snaps
to even dimensions (H.264 requires them). Power users can override
individual fields: `--max-height N`, `--bitrate N`, and `--framerate N`
each take precedence over the chosen preset's value for that field.
## Known limitations and gotchas ## Known limitations and gotchas
+36
View File
@@ -51,3 +51,39 @@ else
echo "$MPV_LOG" | tail -30 | sed 's/^/ /' echo "$MPV_LOG" | tail -30 | sed 's/^/ /'
exit 1 exit 1
fi fi
# ── quality-preset downscale check ────────────────────────────────────────
# Mirrors the videoscale step host/pipeline.rs inserts for a non-Source preset:
# AFTER videoconvert (scaling system-memory NV12, not the raw source format) and
# pinned to an exact even WxH computed from the source size. A 16:9 source @
# 480p wants width 853.3 -> 852 even. Asserts the negotiated size matches and
# the encoder accepts it. Guards both the "even-width caveat" and the
# negotiation/placement regression that squashed the picture.
SCALED="${TMPDIR:-/tmp}/pixelpass-smoke-scaled-$$.ts"
trap 'rm -f "$OUT" "$SCALED"' EXIT
echo "[smoke] downscale check: 1920x1080 -> 852x480 (videoscale after videoconvert)"
gst-launch-1.0 -q \
mpegtsmux name=mux ! queue ! filesink location="$SCALED" \
videotestsrc num-buffers=30 is-live=false \
! video/x-raw,width=1920,height=1080,framerate=30/1 \
! videorate ! video/x-raw,framerate=30/1 \
! queue ! videoconvert ! video/x-raw,format=NV12 \
! videoscale ! video/x-raw,format=NV12,width=852,height=480 \
! vah264enc rate-control=cbr bitrate=1000 key-int-max=60 \
! h264parse config-interval=-1 \
! video/x-h264,stream-format=byte-stream,alignment=au ! mux. \
audiotestsrc num-buffers=47 is-live=false \
! audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 \
! avenc_aac bitrate=128000 ! aacparse ! mux.
SCALED_DIMS=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height \
-of csv=p=0 "$SCALED" | head -1)
SCALED_W=${SCALED_DIMS%,*}
SCALED_H=${SCALED_DIMS#*,}
echo " negotiated ${SCALED_W}x${SCALED_H}"
if [[ "$SCALED_H" == "480" && $((SCALED_W % 2)) -eq 0 && "$SCALED_W" -lt 1920 ]]; then
echo "[smoke] PASS — downscale produced an even width below source (proportional)"
else
echo "[smoke] FAIL: expected even width < 1920 at height 480, got ${SCALED_W}x${SCALED_H}"
exit 1
fi
+71 -14
View File
@@ -13,6 +13,12 @@ pub struct Cli {
pub ticket: Option<String>, pub ticket: Option<String>,
// ── host options ────────────────────────────────────────────────── // ── host options ──────────────────────────────────────────────────
/// Run as host without the interactive menu. Equivalent to picking
/// "Host" in the menu, but headless — for scripting and the --gui
/// front-end, which drives this binary as a child process.
#[arg(long)]
pub host: bool,
/// Pick a single window instead of the whole screen. /// Pick a single window instead of the whole screen.
#[arg(long)] #[arg(long)]
pub window: bool, pub window: bool,
@@ -21,21 +27,29 @@ pub struct Cli {
#[arg(long, value_name = "NAME")] #[arg(long, value_name = "NAME")]
pub app: Option<String>, pub app: Option<String>,
/// Mix in the default microphone source.
#[arg(long)]
pub mic: bool,
/// Override display server autodetection. /// Override display server autodetection.
#[arg(long, value_enum)] #[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>, pub display_server: Option<DisplayServerArg>,
/// Encode bitrate in kbps. /// Quality preset. Bundles a max video height, bitrate, and framerate.
#[arg(long, default_value_t = 6000)] /// `auto` derives them from the saved bandwidth pre-flight (falls back to
pub bitrate: u32, /// `medium` when no measurement exists). Defaults to `auto`; in the
/// interactive menu, omitting this shows a picker instead.
#[arg(long, value_enum)]
pub quality: Option<Quality>,
/// Capture framerate. /// Cap the encoded video height (px); width follows the source aspect.
#[arg(long, default_value_t = 30)] /// Power-user override — takes precedence over the preset's height.
pub framerate: u32, #[arg(long, value_name = "N")]
pub max_height: Option<u32>,
/// Encode bitrate in kbps. Overrides the quality preset's bitrate.
#[arg(long)]
pub bitrate: Option<u32>,
/// Capture framerate. Overrides the quality preset's framerate.
#[arg(long)]
pub framerate: Option<u32>,
/// Disable VAAPI HW encode; force software x264. /// Disable VAAPI HW encode; force software x264.
#[arg(long)] #[arg(long)]
@@ -54,6 +68,17 @@ pub struct Cli {
pub port: u16, pub port: u16,
// ── global ──────────────────────────────────────────────────────── // ── global ────────────────────────────────────────────────────────
/// Launch the graphical front-end (a window with Host/View controls)
/// instead of the terminal menu. Requires a build with `--features gui`.
#[arg(long)]
pub gui: bool,
/// Emit machine-readable events on stdout (one JSON object per line)
/// alongside the human banner on stderr. For scripts and the --gui
/// front-end. Currently only `json` is supported.
#[arg(long, value_enum, value_name = "FORMAT")]
pub output: Option<OutputFormat>,
/// Trace-level logging. /// Trace-level logging.
#[arg(long, short)] #[arg(long, short)]
pub verbose: bool, pub verbose: bool,
@@ -75,14 +100,43 @@ pub enum DisplayServerArg {
X11, X11,
} }
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum OutputFormat {
/// One JSON object per line on stdout.
Json,
}
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
/// tuple — resolution is a quality-per-bitrate knob, so the three only make
/// sense together. `Auto` has no fixed tuple; it picks one of the others from
/// the bandwidth pre-flight at host startup. See `host::quality`.
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum Quality {
/// Native source resolution, 6000 kbps, 30 fps (no downscale).
Source,
/// Up to 1080p, 4000 kbps, 30 fps.
High,
/// Up to 720p, 2500 kbps, 30 fps.
Medium,
/// Up to 480p, 1000 kbps, 30 fps.
Low,
/// Derive from the measured upstream; falls back to `medium` when unmeasured.
Auto,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HostOpts { pub struct HostOpts {
pub window: bool, pub window: bool,
pub app: Option<String>, pub app: Option<String>,
pub mic: bool,
pub display_server: Option<DisplayServerArg>, pub display_server: Option<DisplayServerArg>,
pub bitrate: u32, /// Chosen preset (Auto = derive at startup). Defaults to Auto.
pub framerate: u32, pub quality: Quality,
/// Raw `--bitrate` override (kbps); None = use the preset's bitrate.
pub bitrate: Option<u32>,
/// Raw `--framerate` override; None = use the preset's framerate.
pub framerate: Option<u32>,
/// Raw `--max-height` override (px); None = use the preset's height.
pub max_height: Option<u32>,
pub no_hwencode: bool, pub no_hwencode: bool,
pub max_viewers: Option<u32>, pub max_viewers: Option<u32>,
pub interactive: bool, pub interactive: bool,
@@ -99,10 +153,13 @@ impl Cli {
HostOpts { HostOpts {
window: self.window, window: self.window,
app: self.app, app: self.app,
mic: self.mic,
display_server: self.display_server, display_server: self.display_server,
// No `--quality` and nothing picked interactively → the documented
// default, Auto.
quality: self.quality.unwrap_or(Quality::Auto),
bitrate: self.bitrate, bitrate: self.bitrate,
framerate: self.framerate, framerate: self.framerate,
max_height: self.max_height,
no_hwencode: self.no_hwencode, no_hwencode: self.no_hwencode,
max_viewers: self.max_viewers, max_viewers: self.max_viewers,
interactive, interactive,
+68 -12
View File
@@ -2,21 +2,49 @@ use anyhow::{Result, bail};
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use crate::cli::HostOpts;
use crate::common::display::DisplayServer; use crate::common::display::DisplayServer;
pub fn check_host_binaries(display: DisplayServer) -> Result<()> { pub fn check_host_binaries(display: DisplayServer, opts: &HostOpts) -> Result<()> {
if display == DisplayServer::Wayland { // Unknown is handled (and rejected) by the caller; nothing to check here.
require("gst-launch-1.0")?; if display == DisplayServer::Unknown {
require("gst-inspect-1.0")?; return Ok(());
require("pactl")?;
require_gst_element("pipewiresrc")?;
require_gst_element("vah264enc")?;
require_gst_element("h264parse")?;
require_gst_element("mpegtsmux")?;
require_gst_element("pulsesrc")?;
require_gst_element("avenc_aac")?;
require_gst_element("aacparse")?;
} }
// Shared across both backends: the gst tools, audio routing, and the
// encode/mux tail elements.
require("gst-launch-1.0")?;
require("gst-inspect-1.0")?;
require("pactl")?;
// videoscale (downscale for the quality presets) lives in plugins-base,
// the same package the gst tools need, so this rarely fails on its own —
// but check it for a clear error if a partial install is missing it.
require_gst_element("videoscale")?;
require_gst_element("h264parse")?;
require_gst_element("mpegtsmux")?;
require_gst_element("pulsesrc")?;
require_gst_element("avenc_aac")?;
require_gst_element("aacparse")?;
// Encoder depends on --no-hwencode (software x264 vs hardware VAAPI).
if opts.no_hwencode {
require_gst_element("x264enc")?;
} else {
require_gst_element("vah264enc")?;
}
// Per-backend video source, plus the X11 window-picker when --window is set.
match display {
DisplayServer::Wayland => require_gst_element("pipewiresrc")?,
DisplayServer::X11 => {
require_gst_element("ximagesrc")?;
if opts.window {
require("xwininfo")?;
}
}
DisplayServer::Unknown => unreachable!("early-returned above"),
}
Ok(()) Ok(())
} }
@@ -69,6 +97,13 @@ fn install_hint_for_bin(bin: &str) -> String {
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils",
_ => "pulseaudio-utils (provides `pactl`)", _ => "pulseaudio-utils (provides `pactl`)",
}, },
"xwininfo" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "xorg-xwininfo",
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "x11-utils",
Some("fedora" | "nobara") => "xorg-x11-utils",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "xwininfo",
_ => "xwininfo (X11 window-info utility)",
},
_ => bin, _ => bin,
}; };
install_command(&distro, pkg) install_command(&distro, pkg)
@@ -91,6 +126,27 @@ fn install_hint_for_gst_element(name: &str) -> String {
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
_ => "the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)", _ => "the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)",
}, },
"x264enc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-ugly",
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-ugly",
Some("fedora" | "nobara") => "gstreamer1-plugins-ugly",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-ugly",
_ => "the GStreamer x264 plugin (plugins-ugly)",
},
"ximagesrc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-good",
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-good",
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
_ => "the GStreamer X11 plugin (plugins-good)",
},
"videoscale" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-base",
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base",
Some("fedora" | "nobara") => "gstreamer1-plugins-base",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base",
_ => "the GStreamer plugins-base set",
},
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() { "h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad", Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
+1
View File
@@ -3,6 +3,7 @@ pub mod bandwidth;
pub mod config; pub mod config;
pub mod deps; pub mod deps;
pub mod display; pub mod display;
pub mod output;
pub mod process; pub mod process;
pub mod signal; pub mod signal;
pub mod tunnel; pub mod tunnel;
+81
View File
@@ -0,0 +1,81 @@
//! Machine-readable event stream for non-interactive front-ends.
//!
//! When enabled with `--output json`, the host and viewer emit one JSON
//! object per line on **stdout**. The human banner and `tracing` logs stay
//! on **stderr**, so the two streams never interleave and a parser reading
//! stdout sees only events. Each line is flushed immediately so a front-end
//! reading the pipe gets events live rather than in block-buffered chunks.
//!
//! This is the shell-out counterpart to an in-process event channel: the
//! `--gui` front-end re-execs this binary as `pixelpass --host --output json`
//! and parses these lines to drive its window.
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use serde::Serialize;
static JSON_ENABLED: AtomicBool = AtomicBool::new(false);
/// Turn JSON event output on. Called once at startup from `--output json`.
pub fn set_json(enabled: bool) {
JSON_ENABLED.store(enabled, Ordering::Relaxed);
}
fn json_enabled() -> bool {
JSON_ENABLED.load(Ordering::Relaxed)
}
/// One event in the stdout stream. Serialized as `{"event":"<tag>", ...}`.
#[derive(Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event<'a> {
/// The relay-only ticket the viewer needs. Emitted once at host startup.
Ticket { value: &'a str },
/// One-shot host configuration summary, mirroring the banner fields.
HostInfo {
display_server: &'a str,
capture: &'a str,
quality: &'a str,
dimensions: &'a str,
hw_encode: bool,
max_viewers: u32,
max_viewers_source: &'a str,
},
/// A new viewer joined.
ViewerJoined { id: &'a str, active: u32, max: u32 },
/// A viewer disconnected.
ViewerLeft { id: &'a str, active: u32, max: u32 },
/// Capture pipeline lifecycle (spawned on first viewer, torn down on last).
Capture { state: CaptureState },
/// A viewer was turned away (host full, or capture spawn failed).
ViewerRefused { reason: &'a str },
/// Viewer-side: the local player URL is ready to open.
Connected { url: &'a str },
}
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CaptureState {
Started,
Stopped,
}
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
/// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching.
pub fn emit(event: Event) {
if !json_enabled() {
return;
}
match serde_json::to_string(&event) {
Ok(line) => {
let mut out = std::io::stdout().lock();
// Best-effort: a closed pipe (front-end gone) shouldn't crash the
// host — it keeps streaming to any viewers already connected.
let _ = writeln!(out, "{line}");
let _ = out.flush();
}
Err(e) => tracing::warn!("failed to serialize event: {e}"),
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Drives a headless `pixelpass` child process for the GUI.
//!
//! The GUI re-execs this same binary (via [`std::env::current_exe`]) in
//! headless mode with `--output json`, then reads the child's JSON event
//! stream on a background thread and forwards parsed events over a channel the
//! egui app drains each frame. stderr is captured into a small ring so a
//! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use eframe::egui;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use serde::Deserialize;
/// One parsed event from the child's stdout. Owned mirror of
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so
/// the wire format and the parser can evolve independently.
#[derive(Deserialize, Debug)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum ChildEvent {
Ticket {
value: String,
},
HostInfo {
display_server: String,
capture: String,
quality: String,
dimensions: String,
hw_encode: bool,
max_viewers: u32,
max_viewers_source: String,
},
ViewerJoined {
id: String,
active: u32,
max: u32,
},
ViewerLeft {
id: String,
active: u32,
max: u32,
},
Capture {
state: CaptureState,
},
ViewerRefused {
reason: String,
},
Connected {
url: String,
},
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CaptureState {
Started,
Stopped,
}
const STDERR_TAIL_MAX: usize = 60;
pub struct ChildProc {
child: Child,
pub rx: Receiver<ChildEvent>,
stderr_tail: Arc<Mutex<Vec<String>>>,
stdin: std::process::ChildStdin,
}
impl ChildProc {
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. `ctx`
/// is repainted whenever an event arrives so the UI updates live.
pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result<Self> {
let exe = std::env::current_exe()?;
let mut child = Command::new(exe)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let (tx, rx) = std::sync::mpsc::channel();
let stdin = child.stdin.take().expect("stdin piped");
let stdout = child.stdout.take().expect("stdout piped");
std::thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
// Ignore any non-event line rather than dropping the stream.
if let Ok(ev) = serde_json::from_str::<ChildEvent>(&line) {
if tx.send(ev).is_err() {
break; // app gone
}
ctx.request_repaint();
}
}
});
let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new()));
let stderr = child.stderr.take().expect("stderr piped");
let tail = stderr_tail.clone();
std::thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
let mut t = tail.lock().unwrap();
t.push(line);
let overflow = t.len().saturating_sub(STDERR_TAIL_MAX);
if overflow > 0 {
t.drain(0..overflow);
}
}
});
Ok(Self {
child,
rx,
stderr_tail,
stdin,
})
}
/// Whether the child is still running.
pub fn is_alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
/// The last captured stderr lines, joined — for error display.
pub fn stderr_tail(&self) -> String {
self.stderr_tail.lock().unwrap().join("\n")
}
/// Send a newline-terminated command to the child.
pub fn send_command(&mut self, cmd: &str) {
use std::io::Write;
if let Err(e) = writeln!(self.stdin, "{cmd}") {
tracing::warn!("failed to send command to child: {e}");
}
}
/// Gracefully stop the child: SIGINT (so the host runs its ctrl-c teardown
/// — tears down capture, closes the endpoint), with a ~2 s grace period
/// before a hard kill. Idempotent.
pub fn stop(&mut self) {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return; // already exited
}
let _ = kill(Pid::from_raw(self.child.id() as i32), Signal::SIGINT);
for _ in 0..40 {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for ChildProc {
fn drop(&mut self) {
// Closing the window (dropping the app, hence the session) must not
// orphan a live host child streaming to viewers.
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::output::{CaptureState as EmitState, Event};
// The GUI parses what the headless child emits. These round-trip the
// emitter's own types (`common::output::Event`) through the parser
// (`ChildEvent`) so a rename on either side of the wire fails here rather
// than silently breaking the GUI at runtime.
fn parse(emit: Event) -> ChildEvent {
let line = serde_json::to_string(&emit).unwrap();
serde_json::from_str::<ChildEvent>(&line).unwrap()
}
#[test]
fn ticket_round_trips() {
assert!(matches!(
parse(Event::Ticket { value: "endpointXYZ" }),
ChildEvent::Ticket { value } if value == "endpointXYZ"
));
}
#[test]
fn host_info_round_trips() {
let ev = parse(Event::HostInfo {
display_server: "Wayland",
capture: "fullscreen + system-audio",
quality: "Medium",
dimensions: "≤720p / 2500 kbps / 30 fps",
hw_encode: true,
max_viewers: 3,
max_viewers_source: "user-specified",
});
match ev {
ChildEvent::HostInfo {
display_server,
quality,
hw_encode,
max_viewers,
..
} => {
assert_eq!(display_server, "Wayland");
assert_eq!(quality, "Medium");
assert!(hw_encode);
assert_eq!(max_viewers, 3);
}
other => panic!("expected HostInfo, got {other:?}"),
}
}
#[test]
fn viewer_events_round_trips() {
assert!(matches!(
parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }),
ChildEvent::ViewerJoined { id, active: 2, max: 4 } if id == "nodeXYZ"
));
assert!(matches!(
parse(Event::ViewerLeft { id: "nodeXYZ", active: 1, max: 4 }),
ChildEvent::ViewerLeft { id, active: 1, max: 4 } if id == "nodeXYZ"
));
}
#[test]
fn capture_state_round_trips() {
assert!(matches!(
parse(Event::Capture { state: EmitState::Started }),
ChildEvent::Capture { state: CaptureState::Started }
));
assert!(matches!(
parse(Event::Capture { state: EmitState::Stopped }),
ChildEvent::Capture { state: CaptureState::Stopped }
));
}
#[test]
fn refused_and_connected_round_trip() {
assert!(matches!(
parse(Event::ViewerRefused { reason: "host is full" }),
ChildEvent::ViewerRefused { reason } if reason == "host is full"
));
assert!(matches!(
parse(Event::Connected { url: "http://127.0.0.1:5000" }),
ChildEvent::Connected { url } if url == "http://127.0.0.1:5000"
));
}
}
+881
View File
@@ -0,0 +1,881 @@
//! Graphical front-end (`pixelpass --gui`), compiled only with the `gui`
//! feature.
//!
//! Architecture: this window is a thin **shell-out** driver. It never touches
//! the capture / portal / gst / iroh machinery directly — instead it re-execs
//! this same binary in headless mode (`pixelpass --host --output json …` or
//! `pixelpass <ticket> --output json`) as a child process and parses the
//! child's JSON event stream (see [`crate::common::output`]) to drive what it
//! shows. That keeps the fragile capture stack sealed in a separate process:
//! the GUI can be closed or crash without taking a live stream down.
mod child;
use eframe::egui;
use self::child::{ChildEvent, ChildProc};
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the
/// main thread (a winit requirement), which is where `main` calls it from.
pub fn run() -> anyhow::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([520.0, 480.0])
.with_min_inner_size([460.0, 380.0])
.with_title("PixelPass"),
..Default::default()
};
eframe::run_native(
"PixelPass",
options,
Box::new(|_cc| Ok(Box::new(PixelPassApp::default()))),
)
.map_err(|e| anyhow::anyhow!("GUI failed to start: {e}"))
}
/// Best-effort clipboard write. Returns whether it succeeded so callers can
/// show an honest "✓ Copied" / fallback hint (the clipboard can be flaky on
/// Wayland, and a silent miss is what left users pasting stale tickets).
fn set_clipboard(text: &str) -> bool {
arboard::Clipboard::new()
.and_then(|mut cb| cb.set_text(text.to_owned()))
.is_ok()
}
/// Best-effort clipboard read, backing the viewer Paste button and the
/// View-screen prefill. `None` on a flaky/empty clipboard — callers just leave
/// the field untouched.
fn get_clipboard() -> Option<String> {
arboard::Clipboard::new()
.and_then(|mut cb| cb.get_text())
.ok()
}
/// Decode the host endpoint id from a ticket string, using the exact same
/// parse the viewer does (`EndpointTicket::from_str`), so the GUI agrees with
/// the child about what's a valid code. `None` means it isn't a pixelpass
/// ticket at all. Used to surface a stale/garbage paste *before* the 15s
/// connect timeout, and to show which host the viewer is dialing — the missing
/// signal that let people repeatedly dial a long-dead host.
fn ticket_endpoint_id(ticket: &str) -> Option<String> {
ticket
.trim()
.parse::<iroh_tickets::endpoint::EndpointTicket>()
.ok()
.map(|t| t.endpoint_addr().id.to_string())
}
/// Eyeball-comparable short form of an endpoint id (full ids are long). Enough
/// to spot that two ids differ; the host and viewer screens show the same
/// truncation so a stale ticket reads as an obvious mismatch.
fn short_id(id: &str) -> String {
let prefix: String = id.chars().take(12).collect();
if prefix.len() < id.len() {
format!("{prefix}")
} else {
prefix
}
}
/// Which screen the single window is currently showing.
#[derive(Default, PartialEq)]
enum Screen {
#[default]
Menu,
Host,
Viewer,
}
/// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality`
/// argument value passed to the child.
#[derive(Default, PartialEq, Clone, Copy)]
enum QualitySel {
#[default]
Auto,
Source,
High,
Medium,
Low,
}
impl QualitySel {
fn as_arg(self) -> &'static str {
match self {
QualitySel::Auto => "auto",
QualitySel::Source => "source",
QualitySel::High => "high",
QualitySel::Medium => "medium",
QualitySel::Low => "low",
}
}
fn label(self) -> &'static str {
match self {
QualitySel::Auto => "Auto — pick from my upload speed",
QualitySel::Source => "Source — native resolution",
QualitySel::High => "High — up to 1080p",
QualitySel::Medium => "Medium — up to 720p",
QualitySel::Low => "Low — up to 480p",
}
}
const ALL: [QualitySel; 5] = [
QualitySel::Auto,
QualitySel::Source,
QualitySel::High,
QualitySel::Medium,
QualitySel::Low,
];
}
/// Player choices for the viewer screen.
#[derive(Default, PartialEq, Clone, Copy)]
enum PlayerSel {
#[default]
Mpv,
Vlc,
}
impl PlayerSel {
fn to_player(self) -> crate::interactive::Player {
match self {
PlayerSel::Mpv => crate::interactive::Player::Mpv,
PlayerSel::Vlc => crate::interactive::Player::Vlc,
}
}
}
/// Host-screen state: the config form fields plus, once started, the running
/// child and the latest values parsed from its event stream.
struct HostState {
// form
quality: QualitySel,
max_viewers: u32, // 0 = let the host auto-size from the bandwidth preflight
no_hwencode: bool,
window: bool,
// running session + accumulated live state
proc: Option<ChildProc>,
ticket: Option<String>,
info: Option<HostInfo>,
active: u32,
max: u32,
capturing: bool,
/// Whether the current ticket made it onto the clipboard (auto-copy on
/// arrival, or a manual Copy click). Drives the "✓ Copied" hint so the
/// user isn't left guessing whether to click Copy — the trap that had
/// people pasting a stale clipboard ticket.
copied: bool,
last_refusal: Option<String>,
error: Option<String>,
viewers: Vec<String>,
tray_icon: Option<tray_icon::TrayIcon>,
}
impl Default for HostState {
fn default() -> Self {
Self {
quality: QualitySel::default(),
max_viewers: 0,
no_hwencode: false,
window: false,
proc: None,
ticket: None,
info: None,
active: 0,
max: 0,
capturing: false,
copied: false,
last_refusal: None,
error: None,
viewers: Vec::new(),
tray_icon: None,
}
}
}
/// The host config summary echoed back by the child's `host_info` event.
struct HostInfo {
display: String,
capture: String,
quality: String,
dimensions: String,
hw_encode: bool,
cap_source: String,
}
/// Viewer-screen state.
#[derive(Default)]
struct ViewerState {
ticket_input: String,
player: PlayerSel,
proc: Option<ChildProc>,
url: Option<String>,
launched: bool,
/// Short endpoint id we're dialing, decoded from the ticket at Connect.
/// Shown in the "Connecting to …" line so a dead host is identifiable.
connecting_to: Option<String>,
/// Set when the View screen opens so the code field grabs focus once
/// (cleared on use, so it doesn't steal focus every frame).
focus_ticket: bool,
error: Option<String>,
}
#[derive(Default)]
struct PixelPassApp {
screen: Screen,
host: HostState,
viewer: ViewerState,
}
impl eframe::App for PixelPassApp {
// eframe 0.34 hands us the central-panel `ui` directly.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Drain any pending child events before drawing this frame.
self.pump_host_events();
self.pump_viewer_events();
if let Ok(_event) = tray_icon::menu::MenuEvent::receiver().try_recv() {
// Only one menu item right now: "Stop Hosting"
self.stop_host();
}
match self.screen {
Screen::Menu => self.menu(ui),
Screen::Host => self.host(ui),
Screen::Viewer => self.viewer(ui),
}
}
}
impl PixelPassApp {
fn menu(&mut self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space(24.0);
ui.heading("PixelPass");
ui.label("P2P screen sharing");
ui.label(
egui::RichText::new(concat!("v", env!("CARGO_PKG_VERSION")))
.small()
.weak(),
);
ui.add_space(32.0);
if ui
.add_sized([260.0, 40.0], egui::Button::new("Host — share my screen"))
.clicked()
{
self.screen = Screen::Host;
}
ui.add_space(8.0);
if ui
.add_sized(
[260.0, 40.0],
egui::Button::new("View — watch someone's screen"),
)
.clicked()
{
self.screen = Screen::Viewer;
self.prefill_viewer_ticket();
}
});
}
// ── Host screen ──────────────────────────────────────────────────────
fn host(&mut self, ui: &mut egui::Ui) {
let running = self.host.proc.is_some();
ui.horizontal(|ui| {
// Leaving the host screen stops the session (Drop on the child).
if ui.button("← Menu").clicked() {
self.stop_host();
self.screen = Screen::Menu;
}
ui.heading("Host");
});
ui.separator();
if running {
self.host_running(ui);
} else {
self.host_form(ui);
}
}
fn host_form(&mut self, ui: &mut egui::Ui) {
if let Some(err) = &self.host.error {
ui.colored_label(egui::Color32::LIGHT_RED, err);
ui.add_space(8.0);
}
egui::Grid::new("host_form")
.num_columns(2)
.spacing([12.0, 10.0])
.show(ui, |ui| {
ui.label("Quality");
egui::ComboBox::from_id_salt("quality")
.selected_text(self.host.quality.label())
.show_ui(ui, |ui| {
for q in QualitySel::ALL {
ui.selectable_value(&mut self.host.quality, q, q.label());
}
});
ui.end_row();
ui.label("Max viewers");
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut self.host.max_viewers).range(0..=16));
if self.host.max_viewers == 0 {
ui.label("(auto from upload speed)");
}
});
ui.end_row();
ui.label("Options");
ui.vertical(|ui| {
ui.checkbox(&mut self.host.window, "Share a single window");
ui.checkbox(
&mut self.host.no_hwencode,
"Software encoding (no GPU / VAAPI)",
);
});
ui.end_row();
});
ui.add_space(16.0);
if ui
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
.clicked()
{
self.start_host(ui.ctx().clone());
}
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"On Wayland a \"Share Screen?\" dialog appears when the first \
viewer connects.",
)
.small()
.weak(),
);
}
fn host_running(&mut self, ui: &mut egui::Ui) {
if self.host.capturing {
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
} else if self.host.ticket.is_some() {
ui.colored_label(egui::Color32::YELLOW, "● Waiting for viewers…");
} else {
ui.label("Starting…");
}
ui.add_space(6.0);
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
if !self.host.viewers.is_empty() {
ui.add_space(4.0);
for id in &self.host.viewers.clone() {
ui.horizontal(|ui| {
ui.label(format!("{}", short_id(id)));
if ui.button("Kick").clicked() {
if let Some(p) = &mut self.host.proc {
p.send_command(&format!("kick {id}"));
}
}
});
}
}
if let Some(info) = &self.host.info {
ui.label(
egui::RichText::new(format!("{} · {}", info.display, info.capture))
.small()
.weak(),
);
ui.label(
egui::RichText::new(format!(
"{} · {} · {} · cap {}",
info.quality,
info.dimensions,
if info.hw_encode {
"HW encode"
} else {
"software encode"
},
info.cap_source
))
.small()
.weak(),
);
}
ui.add_space(12.0);
if let Some(ticket) = self.host.ticket.clone() {
ui.label("Share this code with your viewer(s):");
if let Some(id) = ticket_endpoint_id(&ticket) {
// The viewer shows "Connecting to <id>…" with this same
// truncation, so the two ends can be eyeballed for a match.
ui.label(
egui::RichText::new(format!("This host: endpoint {}", short_id(&id)))
.small()
.weak(),
);
}
ui.add_space(4.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.add(
egui::Label::new(egui::RichText::new(&ticket).monospace().small())
.wrap()
.selectable(true),
);
});
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button("📋 Copy code").clicked() {
self.copy_to_clipboard(&ticket);
}
if self.host.copied {
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Copied to clipboard");
}
});
if !self.host.copied {
ui.label(
egui::RichText::new(
"Couldn't auto-copy — click Copy, or select the code above.",
)
.small()
.weak(),
);
}
}
if let Some(reason) = &self.host.last_refusal {
ui.add_space(8.0);
ui.colored_label(
egui::Color32::from_rgb(220, 160, 60),
format!("{reason}"),
);
}
ui.add_space(16.0);
if ui
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
.clicked()
{
self.stop_host();
}
}
fn start_host(&mut self, ctx: egui::Context) {
self.host.error = None;
self.host.last_refusal = None;
self.host.ticket = None;
self.host.info = None;
self.host.active = 0;
self.host.max = 0;
self.host.capturing = false;
self.host.copied = false;
let mut args = vec![
"--host".to_string(),
"--output".to_string(),
"json".to_string(),
"--quality".to_string(),
self.host.quality.as_arg().to_string(),
];
if self.host.max_viewers > 0 {
args.push("--max-viewers".to_string());
args.push(self.host.max_viewers.to_string());
}
if self.host.no_hwencode {
args.push("--no-hwencode".to_string());
}
if self.host.window {
args.push("--window".to_string());
}
match ChildProc::spawn(&args, ctx) {
Ok(p) => {
self.host.proc = Some(p);
#[cfg(target_os = "linux")]
if let Err(e) = gtk::init() {
tracing::warn!("Failed to initialize GTK for system tray: {e}");
}
use tray_icon::{TrayIconBuilder, menu::{Menu, MenuItem}};
let menu = Menu::new();
let _ = menu.append(&MenuItem::new("Stop Hosting", true, None));
let icon = tray_icon::Icon::from_rgba(vec![255, 0, 0, 255], 1, 1).unwrap();
if let Ok(tray) = TrayIconBuilder::new()
.with_title("PixelPass")
.with_tooltip("PixelPass Screen Sharing")
.with_icon(icon)
.with_menu(Box::new(menu))
.build()
{
self.host.tray_icon = Some(tray);
}
}
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
}
}
fn stop_host(&mut self) {
// Dropping the ChildProc SIGINTs the child and reaps it.
self.host.proc = None;
self.host.capturing = false;
self.host.ticket = None;
self.host.copied = false;
self.host.viewers.clear();
self.host.tray_icon = None;
}
/// Drain the host child's event channel into state, and detect an
/// unexpected child exit (e.g. a failed dependency check) so it surfaces
/// in the form instead of leaving a dead "running" view.
fn pump_host_events(&mut self) {
let events: Vec<ChildEvent> = match &self.host.proc {
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
None => return,
};
for ev in events {
self.apply_host_event(ev);
}
if let Some(p) = &mut self.host.proc
&& !p.is_alive()
{
if self.host.ticket.is_none() {
let tail = p.stderr_tail();
self.host.error = Some(if tail.trim().is_empty() {
"Host exited before it could start.".to_string()
} else {
format!("Host exited before it could start:\n{tail}")
});
}
self.host.proc = None;
self.host.capturing = false;
}
}
fn apply_host_event(&mut self, ev: ChildEvent) {
match ev {
ChildEvent::Ticket { value } => {
// Auto-copy on arrival, mirroring the CLI/interactive host
// (which copies the ticket and prints "copied to your
// clipboard"). A failure here is non-fatal: the ticket stays
// visible for manual copy, and `copied` stays false so the UI
// doesn't falsely claim success.
self.host.copied = set_clipboard(&value);
self.host.ticket = Some(value);
}
ChildEvent::HostInfo {
display_server,
capture,
quality,
dimensions,
hw_encode,
max_viewers,
max_viewers_source,
} => {
self.host.max = max_viewers;
self.host.info = Some(HostInfo {
display: display_server,
capture,
quality,
dimensions,
hw_encode,
cap_source: max_viewers_source,
});
}
ChildEvent::ViewerJoined { id, active, max } => {
self.host.active = active;
self.host.max = max;
if !self.host.viewers.contains(&id) {
self.host.viewers.push(id.clone());
let _ = notify_rust::Notification::new()
.summary("PixelPass Viewer Connected")
.body(&format!("Viewer {} joined the stream.", short_id(&id)))
.show();
}
}
ChildEvent::ViewerLeft { id, active, max } => {
self.host.active = active;
self.host.max = max;
self.host.viewers.retain(|v| v != &id);
let _ = notify_rust::Notification::new()
.summary("PixelPass Viewer Disconnected")
.body(&format!("Viewer {} left the stream.", short_id(&id)))
.show();
}
ChildEvent::Capture { state } => {
self.host.capturing = matches!(state, child::CaptureState::Started);
}
ChildEvent::ViewerRefused { reason } => self.host.last_refusal = Some(reason),
ChildEvent::Connected { .. } => {} // viewer-side; not used on the host screen
}
}
/// Manual Copy-button handler: copy and reflect success in the UI, or
/// surface a clear error if the clipboard rejected it.
fn copy_to_clipboard(&mut self, text: &str) {
if set_clipboard(text) {
self.host.copied = true;
self.host.error = None;
} else {
self.host.copied = false;
self.host.error = Some(
"Couldn't write to the clipboard. Select the code above and copy it manually."
.to_string(),
);
}
}
// ── Viewer screen ─────────────────────────────────────────────────────
fn viewer(&mut self, ui: &mut egui::Ui) {
let running = self.viewer.proc.is_some();
ui.horizontal(|ui| {
if ui.button("← Menu").clicked() {
self.stop_viewer();
self.screen = Screen::Menu;
}
ui.heading("View");
});
ui.separator();
if running {
self.viewer_running(ui);
} else {
self.viewer_form(ui);
}
}
fn viewer_form(&mut self, ui: &mut egui::Ui) {
if let Some(err) = &self.viewer.error {
ui.colored_label(egui::Color32::LIGHT_RED, err);
ui.add_space(8.0);
}
ui.label("Paste the share code you received:");
ui.add_space(4.0);
// A Paste button (read-side mirror of the host's Copy button — one
// click grabs the code the host just put on the clipboard) with the
// field filling the rest of the row. A single horizontal row, so it
// doesn't grab the panel's full height.
let ticket_resp = ui
.horizontal(|ui| {
if ui.button("📋 Paste").clicked()
&& let Some(text) = get_clipboard()
{
self.viewer.ticket_input = text.trim().to_string();
}
ui.add(
egui::TextEdit::singleline(&mut self.viewer.ticket_input)
.desired_width(f32::INFINITY)
.hint_text("endpoint…"),
)
})
.inner;
// Grab focus once on screen entry so the user can paste/type straight
// away without first clicking into the field.
if std::mem::take(&mut self.viewer.focus_ticket) {
ticket_resp.request_focus();
}
// Enter in the field connects (gated on a decodable code below).
let enter_pressed =
ticket_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
// Decode the pasted code live: confirms it's a real ticket and shows
// which host it points at, so a stale clipboard paste is caught here
// instead of after the 15s connect timeout.
let trimmed = self.viewer.ticket_input.trim().to_string();
let decoded_id = ticket_endpoint_id(&trimmed);
if !trimmed.is_empty() {
ui.add_space(4.0);
match &decoded_id {
Some(id) => ui.colored_label(
egui::Color32::LIGHT_GREEN,
format!("→ endpoint {}", short_id(id)),
),
None => ui.colored_label(
egui::Color32::from_rgb(220, 160, 60),
"⚠ This doesn't look like a share code.",
),
};
}
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label("Player");
egui::ComboBox::from_id_salt("player")
.selected_text(match self.viewer.player {
PlayerSel::Mpv => "mpv",
PlayerSel::Vlc => "VLC",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.viewer.player, PlayerSel::Mpv, "mpv");
ui.selectable_value(&mut self.viewer.player, PlayerSel::Vlc, "VLC");
});
});
ui.add_space(16.0);
// Only dial a code that actually decodes — no point spawning a child to
// spend 15s timing out against garbage. Enter takes the same path.
let connect_clicked = ui
.add_enabled(
decoded_id.is_some(),
egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)),
)
.on_disabled_hover_text("Paste a valid share code first.")
.clicked();
if decoded_id.is_some() && (connect_clicked || enter_pressed) {
self.start_viewer(ui.ctx().clone());
}
}
fn viewer_running(&mut self, ui: &mut egui::Ui) {
if self.viewer.launched {
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
ui.label("Player launched. Close it or disconnect to stop.");
} else if self.viewer.url.is_some() {
ui.label("Connected — launching player…");
} else {
let msg = match &self.viewer.connecting_to {
Some(id) => format!("● Connecting to {id}"),
None => "● Connecting…".to_string(),
};
ui.colored_label(egui::Color32::YELLOW, msg);
}
ui.add_space(16.0);
if ui
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
.clicked()
{
self.stop_viewer();
}
}
/// On entering the View screen, drop a clipboard ticket straight into the
/// field — the host's Copy button (and our auto-copy) usually leaves the
/// freshly-shared code right there. Guarded so it only fires when the field
/// is empty and the clipboard holds a *decodable* ticket, so stray
/// clipboard text never lands in the box; the live decode still shows the
/// id for the user to verify.
fn prefill_viewer_ticket(&mut self) {
if self.viewer.ticket_input.trim().is_empty()
&& self.viewer.proc.is_none()
&& let Some(text) = get_clipboard()
&& ticket_endpoint_id(&text).is_some()
{
self.viewer.ticket_input = text.trim().to_string();
}
self.viewer.focus_ticket = true;
}
fn start_viewer(&mut self, ctx: egui::Context) {
self.viewer.error = None;
self.viewer.url = None;
self.viewer.launched = false;
let ticket = self.viewer.ticket_input.trim().to_string();
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
let args = vec![ticket, "--output".to_string(), "json".to_string()];
match ChildProc::spawn(&args, ctx) {
Ok(p) => self.viewer.proc = Some(p),
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
}
}
fn stop_viewer(&mut self) {
self.viewer.proc = None;
self.viewer.url = None;
self.viewer.launched = false;
self.viewer.connecting_to = None;
}
fn pump_viewer_events(&mut self) {
let events: Vec<ChildEvent> = match &self.viewer.proc {
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
None => return,
};
for ev in events {
if let ChildEvent::Connected { url } = ev {
self.viewer.url = Some(url.clone());
if !self.viewer.launched {
match self.viewer.player.to_player().spawn(&url) {
Ok(()) => self.viewer.launched = true,
Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")),
}
}
}
}
if let Some(p) = &mut self.viewer.proc
&& !p.is_alive()
{
// Bridge ended (player closed) or the connection failed.
if self.viewer.url.is_none() {
let tail = p.stderr_tail();
self.viewer.error = Some(if tail.trim().is_empty() {
"Couldn't connect to the host (check the code).".to_string()
} else {
format!("Connection ended:\n{tail}")
});
}
self.viewer.proc = None;
self.viewer.launched = false;
self.viewer.url = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A real, parseable ticket built the same way the host builds one
/// (`EndpointTicket::new`), from a deterministic key so the id is stable.
fn sample_ticket() -> (String, String) {
let sk = iroh::SecretKey::from_bytes(&[7u8; 32]);
let id = sk.public().to_string();
let ticket = iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new(
sk.public(),
))
.to_string();
(ticket, id)
}
#[test]
fn decodes_endpoint_id_from_a_valid_ticket() {
let (ticket, id) = sample_ticket();
assert_eq!(ticket_endpoint_id(&ticket).as_deref(), Some(id.as_str()));
}
#[test]
fn tolerates_surrounding_whitespace() {
let (ticket, _) = sample_ticket();
assert!(ticket_endpoint_id(&format!(" \n{ticket}\t ")).is_some());
}
#[test]
fn rejects_non_ticket_input() {
// The empty/whitespace/garbage cases that gate the Connect button off.
assert_eq!(ticket_endpoint_id(""), None);
assert_eq!(ticket_endpoint_id(" "), None);
assert_eq!(ticket_endpoint_id("hello world"), None);
assert_eq!(ticket_endpoint_id("endpointbutnotreally"), None);
}
#[test]
fn short_id_truncates_long_ids() {
assert_eq!(short_id("0123456789abcdefghij"), "0123456789ab…");
}
#[test]
fn short_id_leaves_short_or_exact_ids_intact() {
assert_eq!(short_id("abc"), "abc");
assert_eq!(short_id("0123456789ab"), "0123456789ab"); // exactly 12, no ellipsis
}
}
+16 -31
View File
@@ -1,40 +1,25 @@
//! Capture dispatcher. Selects the per-display-server pipeline and returns its //! Capture dispatcher. Picks the per-display-server source backend and returns
//! [`CaptureHandle`]. Each backend owns its own children and tears them down //! the shared [`pipeline::CaptureHandle`]. The handle itself, its teardown, and
//! via its own Drop / shutdown. //! the whole encode/serve tail are display-agnostic and live in
//! [`super::pipeline`]; the backends differ only in how they obtain a video
//! source element.
use anyhow::{Result, bail}; use anyhow::Result;
use crate::cli::HostOpts; use crate::cli::HostOpts;
use crate::common::display::DisplayServer; use crate::common::display::DisplayServer;
use crate::host::wayland; use crate::host::pipeline::CaptureHandle;
use crate::host::quality::EffectiveQuality;
use crate::host::{wayland, x11};
pub enum CaptureHandle { pub async fn spawn(
Wayland(wayland::CaptureHandle), display: DisplayServer,
} opts: &HostOpts,
quality: &EffectiveQuality,
impl CaptureHandle { ) -> Result<CaptureHandle> {
pub fn local_port(&self) -> u16 {
match self {
CaptureHandle::Wayland(h) => h.local_port(),
}
}
pub async fn shutdown(self) {
match self {
CaptureHandle::Wayland(h) => h.shutdown().await,
}
}
}
pub async fn spawn(display: DisplayServer, opts: &HostOpts) -> Result<CaptureHandle> {
match display { match display {
DisplayServer::Wayland => { DisplayServer::Wayland => wayland::start(opts, quality).await,
let h = wayland::start(opts).await?; DisplayServer::X11 => x11::start(opts, quality).await,
Ok(CaptureHandle::Wayland(h))
}
DisplayServer::X11 => {
bail!("X11 capture pipeline not yet implemented (Phase 2 follow-up)");
}
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"), DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
} }
} }
+109 -28
View File
@@ -1,38 +1,50 @@
pub mod audio; pub mod audio;
mod capture; mod capture;
mod pipeline;
mod quality;
mod serve; mod serve;
mod wayland; mod wayland;
mod x11;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use iroh::endpoint::{Connection, presets}; use iroh::endpoint::{Connection, presets};
use iroh::{Endpoint, EndpointAddr}; use iroh::{Endpoint, EndpointAddr};
use iroh_tickets::endpoint::EndpointTicket; use iroh_tickets::endpoint::EndpointTicket;
use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::cli::HostOpts; use crate::cli::HostOpts;
use crate::common::{ use crate::common::{
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, signal, alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output,
tunnel, signal, tunnel,
}; };
use self::capture::CaptureHandle; use self::pipeline::CaptureHandle;
use self::quality::EffectiveQuality;
/// Messages from per-viewer tasks to the capture supervisor. /// Messages from per-viewer tasks to the capture supervisor.
enum SupervisorMsg { enum SupervisorMsg {
/// A new viewer wants in. Supervisor replies with the local capture /// A new viewer wants in. Supervisor replies with the local capture
/// HTTP port to connect to, or an error string if the host is full or /// HTTP port to connect to, or an error string if the host is full or
/// capture spawn failed. /// capture spawn failed.
AddViewer(oneshot::Sender<Result<u16, String>>), AddViewer {
id: String,
cancel: CancellationToken,
reply: oneshot::Sender<Result<u16, String>>,
},
/// A viewer's session ended. Supervisor decrements the count and tears /// A viewer's session ended. Supervisor decrements the count and tears
/// down capture if it just hit zero. /// down capture if it just hit zero.
RemoveViewer, RemoveViewer { id: String },
/// Request to kick a specific viewer.
KickViewer { id: String },
} }
pub async fn run(opts: HostOpts) -> Result<()> { pub async fn run(opts: HostOpts) -> Result<()> {
let display = DisplayServer::resolve(opts.display_server); let display = DisplayServer::resolve(opts.display_server);
deps::check_host_binaries(display)?; deps::check_host_binaries(display, &opts)?;
if display == DisplayServer::Unknown { if display == DisplayServer::Unknown {
bail!( bail!(
@@ -41,7 +53,15 @@ pub async fn run(opts: HostOpts) -> Result<()> {
); );
} }
let resolution = resolve_max_viewers(&opts); // Resolve quality first: Auto sizes its bandwidth budget against the viewer
// cap the host will honor. To avoid a circular dependency (the auto-derived
// cap itself depends on bitrate), Auto sizes against the user's explicit
// --max-viewers when given, else a single viewer. The resulting effective
// bitrate then feeds the cap resolution below.
let sizing_viewers = opts.max_viewers.filter(|&n| n > 0).unwrap_or(1);
let quality = quality::resolve(&opts, sizing_viewers);
let resolution = resolve_max_viewers(&opts, quality.bitrate);
if resolution.value == 0 { if resolution.value == 0 {
bail!("--max-viewers must be at least 1"); bail!("--max-viewers must be at least 1");
} }
@@ -73,11 +93,44 @@ pub async fn run(opts: HostOpts) -> Result<()> {
let relay_only = let relay_only =
EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned()); EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned());
let ticket = EndpointTicket::new(relay_only); let ticket = EndpointTicket::new(relay_only);
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string()); let ticket_str = ticket.to_string();
print_host_banner(&ticket, display, &opts, &resolution, clipboard_ok); let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket_str);
print_host_banner(&ticket, display, &opts, &quality, &resolution, clipboard_ok);
output::emit(output::Event::Ticket { value: &ticket_str });
let display_str = format!("{display:?}");
let capture = capture_summary(&opts);
let dims = quality.dimensions_summary();
let cap_source = resolution.source.label();
output::emit(output::Event::HostInfo {
display_server: &display_str,
capture: &capture,
quality: &quality.label,
dimensions: &dims,
hw_encode: !opts.no_hwencode,
max_viewers: resolution.value,
max_viewers_source: &cap_source,
});
let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16); let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
let supervisor = tokio::spawn(supervise(opts.clone(), display, resolution.value, sup_rx)); let supervisor = tokio::spawn(supervise(
opts.clone(),
quality,
display,
resolution.value,
sup_rx,
));
// Stdin listener for "kick <id>"
let stdin_sup_tx = sup_tx.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(tokio::io::stdin()).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(id) = line.strip_prefix("kick ") {
let _ = stdin_sup_tx.send(SupervisorMsg::KickViewer { id: id.trim().to_string() }).await;
}
}
});
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await; accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
@@ -125,9 +178,11 @@ async fn handle_peer(
cancel: CancellationToken, cancel: CancellationToken,
) { ) {
let remote = conn.remote_id(); let remote = conn.remote_id();
let id_str = remote.to_string();
let peer_cancel = CancellationToken::new();
let (reply_tx, reply_rx) = oneshot::channel(); let (reply_tx, reply_rx) = oneshot::channel();
if sup_tx.send(SupervisorMsg::AddViewer(reply_tx)).await.is_err() { if sup_tx.send(SupervisorMsg::AddViewer { id: id_str.clone(), cancel: peer_cancel.clone(), reply: reply_tx }).await.is_err() {
tracing::warn!(%remote, "supervisor channel closed; dropping peer"); tracing::warn!(%remote, "supervisor channel closed; dropping peer");
return; return;
} }
@@ -148,7 +203,7 @@ async fn handle_peer(
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
tracing::warn!(%remote, "accept_bi failed: {e:#}"); tracing::warn!(%remote, "accept_bi failed: {e:#}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await; let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
return; return;
} }
}; };
@@ -159,7 +214,7 @@ async fn handle_peer(
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::warn!(%remote, "connect_to_capture failed: {e:#}"); tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await; let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
return; return;
} }
}; };
@@ -173,10 +228,13 @@ async fn handle_peer(
_ = cancel.cancelled() => { _ = cancel.cancelled() => {
tracing::info!(%remote, "cancellation during stream"); tracing::info!(%remote, "cancellation during stream");
} }
_ = peer_cancel.cancelled() => {
tracing::info!(%remote, "kicked by host");
}
} }
eprintln!("[pixelpass] viewer disconnected: {remote}"); eprintln!("[pixelpass] viewer disconnected: {remote}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await; let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
} }
/// Owns the single shared CaptureHandle and the active viewer count. Spawns /// Owns the single shared CaptureHandle and the active viewer count. Spawns
@@ -185,27 +243,35 @@ async fn handle_peer(
/// the count is already at the cap. /// the count is already at the cap.
async fn supervise( async fn supervise(
opts: HostOpts, opts: HostOpts,
quality: EffectiveQuality,
display: DisplayServer, display: DisplayServer,
max_viewers: u32, max_viewers: u32,
mut rx: mpsc::Receiver<SupervisorMsg>, mut rx: mpsc::Receiver<SupervisorMsg>,
) { ) {
let mut handle: Option<CaptureHandle> = None; let mut handle: Option<CaptureHandle> = None;
let mut count: u32 = 0; let mut count: u32 = 0;
let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
while let Some(msg) = rx.recv().await { while let Some(msg) = rx.recv().await {
match msg { match msg {
SupervisorMsg::AddViewer(reply) => { SupervisorMsg::AddViewer { id, cancel, reply } => {
if count >= max_viewers { if count >= max_viewers {
let _ = reply.send(Err(format!( let reason =
"host is full ({count} of {max_viewers} viewers connected)" format!("host is full ({count} of {max_viewers} viewers connected)");
))); output::emit(output::Event::ViewerRefused { reason: &reason });
let _ = reply.send(Err(reason));
continue; continue;
} }
if handle.is_none() { if handle.is_none() {
tracing::info!("first viewer arriving — spawning capture"); tracing::info!("first viewer arriving — spawning capture");
match capture::spawn(display, &opts).await { match capture::spawn(display, &opts, &quality).await {
Ok(h) => handle = Some(h), Ok(h) => {
handle = Some(h);
output::emit(output::Event::Capture {
state: output::CaptureState::Started,
});
}
Err(e) => { Err(e) => {
let _ = reply.send(Err(format!("capture spawn failed: {e:#}"))); let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
continue; continue;
@@ -215,17 +281,30 @@ async fn supervise(
let port = handle.as_ref().expect("handle was just set").local_port(); let port = handle.as_ref().expect("handle was just set").local_port();
count += 1; count += 1;
viewers.insert(id.clone(), cancel);
let _ = reply.send(Ok(port)); let _ = reply.send(Ok(port));
output::emit(output::Event::ViewerJoined { id: &id, active: count, max: max_viewers });
tracing::info!(active = count, cap = max_viewers, "viewer joined"); tracing::info!(active = count, cap = max_viewers, "viewer joined");
} }
SupervisorMsg::RemoveViewer => { SupervisorMsg::RemoveViewer { id } => {
viewers.remove(&id);
count = count.saturating_sub(1); count = count.saturating_sub(1);
output::emit(output::Event::ViewerLeft { id: &id, active: count, max: max_viewers });
tracing::info!(active = count, cap = max_viewers, "viewer left"); tracing::info!(active = count, cap = max_viewers, "viewer left");
if count == 0 if count == 0
&& let Some(h) = handle.take() && let Some(h) = handle.take()
{ {
tracing::info!("last viewer left — tearing down capture"); tracing::info!("last viewer left — tearing down capture");
h.shutdown().await; h.shutdown().await;
output::emit(output::Event::Capture {
state: output::CaptureState::Stopped,
});
}
}
SupervisorMsg::KickViewer { id } => {
if let Some(cancel) = viewers.get(&id) {
tracing::info!(%id, "kicking viewer");
cancel.cancel();
} }
} }
} }
@@ -234,6 +313,9 @@ async fn supervise(
if let Some(h) = handle.take() { if let Some(h) = handle.take() {
tracing::info!("host shutdown — tearing down capture"); tracing::info!("host shutdown — tearing down capture");
h.shutdown().await; h.shutdown().await;
output::emit(output::Event::Capture {
state: output::CaptureState::Stopped,
});
} }
} }
@@ -241,6 +323,7 @@ fn print_host_banner(
ticket: &EndpointTicket, ticket: &EndpointTicket,
display: DisplayServer, display: DisplayServer,
opts: &HostOpts, opts: &HostOpts,
quality: &EffectiveQuality,
resolution: &MaxViewersResolution, resolution: &MaxViewersResolution,
clipboard_ok: bool, clipboard_ok: bool,
) { ) {
@@ -248,8 +331,9 @@ fn print_host_banner(
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────"); eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
eprintln!("│ display server : {display:?}"); eprintln!("│ display server : {display:?}");
eprintln!("│ capture : {}", capture_summary(opts)); eprintln!("│ capture : {}", capture_summary(opts));
eprintln!("bitrate / fps : {} kbps @ {} fps", opts.bitrate, opts.framerate); eprintln!("quality : {} {}", quality.label, quality.dimensions_summary());
eprintln!("hw encode : {}", if opts.no_hwencode { "off" } else { "auto (VAAPI if available)" }); eprintln!(" ({})", quality.note);
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off (software x264)" } else { "on (VAAPI H.264)" });
eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label()); eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label());
eprintln!(""); eprintln!("");
if clipboard_ok { if clipboard_ok {
@@ -300,7 +384,7 @@ impl MaxViewersSource {
} }
} }
fn resolve_max_viewers(opts: &HostOpts) -> MaxViewersResolution { fn resolve_max_viewers(opts: &HostOpts, effective_bitrate: u32) -> MaxViewersResolution {
if let Some(n) = opts.max_viewers { if let Some(n) = opts.max_viewers {
return MaxViewersResolution { return MaxViewersResolution {
value: n, value: n,
@@ -311,7 +395,7 @@ fn resolve_max_viewers(opts: &HostOpts) -> MaxViewersResolution {
&& cfg.bandwidth.status == BandwidthStatus::Measured && cfg.bandwidth.status == BandwidthStatus::Measured
&& let Some(upstream) = cfg.bandwidth.upstream_mbps && let Some(upstream) = cfg.bandwidth.upstream_mbps
{ {
let n = bandwidth::recommended_max_viewers(upstream, opts.bitrate); let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate);
return MaxViewersResolution { return MaxViewersResolution {
value: n, value: n,
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream }, source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
@@ -340,8 +424,5 @@ fn capture_summary(opts: &HostOpts) -> String {
} else { } else {
bits.push("system-audio".to_string()); bits.push("system-audio".to_string());
} }
if opts.mic {
bits.push("mic".to_string());
}
bits.join(" + ") bits.join(" + ")
} }
+326
View File
@@ -0,0 +1,326 @@
//! Display-server-agnostic capture pipeline. The video *source* element is the
//! only part that differs between Wayland (`pipewiresrc`, after a portal
//! handshake) and X11 (`ximagesrc`); everything downstream — the videorate cap,
//! the encoder, `h264parse`, `mpegtsmux`, the whole audio branch, the gst spawn,
//! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared
//! and lives here. Backends call [`spawn`] with just their source-element args.
use anyhow::{Context, Result, bail};
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::{Child, Command};
use tokio::time::timeout;
use super::audio::Routing;
use super::quality::EffectiveQuality;
use super::serve::Serve;
use crate::cli::HostOpts;
pub struct CaptureHandle {
gst: Option<Child>,
audio: Option<Routing>,
serve: Option<Serve>,
}
impl CaptureHandle {
pub fn local_port(&self) -> u16 {
self.serve
.as_ref()
.expect("serve is always Some until shutdown")
.local_port()
}
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL,
/// unload audio routing (if any), then tear down the serve layer.
/// The serve reader will see EOF on gst stdout and exit on its own;
/// serve.shutdown() is the backstop.
pub async fn shutdown(mut self) {
if let Some(child) = self.gst.as_mut()
&& let Some(pid) = child.id()
{
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
}
if let Some(child) = self.gst.as_mut() {
let _ = timeout(Duration::from_millis(1000), child.wait()).await;
let _ = child.start_kill();
}
if let Some(audio) = self.audio.take() {
audio.shutdown();
}
if let Some(serve) = self.serve.take() {
serve.shutdown().await;
}
}
}
impl Drop for CaptureHandle {
fn drop(&mut self) {
if let Some(child) = self.gst.as_mut() {
let _ = child.start_kill();
}
// Routing's and Serve's own Drop impls handle the rest.
}
}
/// Spawn the shared gst pipeline for a backend that supplies `source_args`
/// (the video-source element + its properties, e.g. `["pipewiresrc", "fd=7",
/// …]` or `["ximagesrc", "use-damage=false", …]`). `source_dims` is the source
/// pixel size when the backend knows it (Wayland from the portal, X11 from
/// root/window geometry); it lets a downscale preset compute an exact even
/// target resolution and skip scaling when the source is already small enough.
/// `after_spawn` runs once, immediately after the gst child is launched —
/// Wayland uses it to `close` the pipewire fd it leaked into the child; X11
/// passes a no-op.
pub async fn spawn(
opts: &HostOpts,
quality: &EffectiveQuality,
source_dims: Option<(u32, u32)>,
source_args: Vec<String>,
after_spawn: impl FnOnce(),
) -> Result<CaptureHandle> {
let (audio_routing, audio_device) = setup_audio(opts).await?;
let args = build_args(&source_args, &audio_device, opts, quality, source_dims);
let mut gst_cmd = Command::new("gst-launch-1.0");
gst_cmd
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() {
gst_cmd.env("GST_DEBUG", "3");
}
let mut gst = gst_cmd.spawn().context("failed to spawn gst-launch-1.0")?;
// Backend-specific post-spawn cleanup (Wayland closes its leaked pw fd here,
// once gst has inherited its own copy).
after_spawn();
let gst_stdout = gst
.stdout
.take()
.context("gst-launch-1.0 stdout pipe unavailable")?;
// Hand stdout to the serve layer, which binds the localhost HTTP listener
// and runs the broadcast fanout. No demux/remux, no codec assumptions.
let serve = Serve::bind(gst_stdout).await?;
Ok(CaptureHandle {
gst: Some(gst),
audio: audio_routing,
serve: Some(serve),
})
}
/// Decide whether per-app audio routing is active and produce the `device=…`
/// argument for `pulsesrc`. Routing activates when either `--app` is set
/// (per-stream rerouting to a per-PID null-sink) or `PIXELPASS_AUDIO_VIA_NULL_SINK=1`
/// is set (no app filter — captures everything via the null-sink, used for
/// dogfooding the loopback path). Otherwise we capture the default sink's
/// monitor (system audio out), not the default source (the mic).
async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
let routing_requested =
opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
let audio_routing = if routing_requested {
Some(
Routing::start(opts)
.await
.context("audio routing setup failed")?,
)
} else {
None
};
let audio_device = if let Some(r) = &audio_routing {
format!("device={}.monitor", r.sink_name())
} else {
let default = default_audio_monitor().await?;
format!("device={default}")
};
Ok((audio_routing, audio_device))
}
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
/// video branch (caller's `source` → videorate cap → optional downscale →
/// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.).
/// Bitrate, framerate, and the downscale height come from the resolved
/// [`EffectiveQuality`]; the encoder and the `videoconvert` target format are
/// selected by `opts.no_hwencode` (hardware VAAPI wants NV12, software x264
/// wants I420).
fn build_args(
source: &[String],
audio_device: &str,
opts: &HostOpts,
quality: &EffectiveQuality,
source_dims: Option<(u32, u32)>,
) -> Vec<String> {
let key_interval = (quality.framerate * 2).to_string();
let bitrate = quality.bitrate.to_string();
let framerate_caps = format!("video/x-raw,framerate={}/1", quality.framerate);
let (raw_format, encoder_args): (&str, Vec<String>) = if opts.no_hwencode {
(
"video/x-raw,format=I420",
vec![
"x264enc".into(),
"tune=zerolatency".into(),
"speed-preset=ultrafast".into(),
format!("bitrate={bitrate}"),
format!("key-int-max={key_interval}"),
],
)
} else {
(
"video/x-raw,format=NV12",
vec![
"vah264enc".into(),
"rate-control=cbr".into(),
format!("bitrate={bitrate}"),
format!("key-int-max={key_interval}"),
],
)
};
// muxer + sink
let mut args: Vec<String> = vec![
"mpegtsmux".into(),
"name=mux".into(),
"!".into(),
"queue".into(),
"!".into(),
"fdsink".into(),
"fd=1".into(),
];
// Downscale step for the quality presets. `None` = encode at native size
// (the "Source" preset, or a source already at/below the target height — we
// never upscale). When the source dimensions are known we pin an exact even
// WxH preserving the source aspect; H.264 4:2:0 needs even dims, so width is
// rounded to even and height is forced even (preset heights already are; a
// raw --max-height override is rounded down). When dims are unknown (a rare
// X11 geometry-read failure) we fall back to height-only + square pixels +
// an even-stepped width range and let videoscale negotiate.
let scale_caps: Option<String> = match quality.max_height {
None => {
tracing::info!(preset = %quality.label, "encoding at native resolution (no downscale)");
None
}
Some(max_h) => {
let h = (max_h & !1).max(2);
match source_dims {
Some((sw, sh)) if sh > h => {
let w = ((sw as u64 * h as u64 + sh as u64 / 2) / sh as u64) as u32;
let w = (w & !1).max(2);
tracing::info!(
preset = %quality.label,
from = %format!("{sw}x{sh}"),
to = %format!("{w}x{h}"),
"downscaling video"
);
Some(format!("{raw_format},width={w},height={h}"))
}
Some((sw, sh)) => {
tracing::info!(
preset = %quality.label,
source = %format!("{sw}x{sh}"),
max_height = h,
"source already at/below preset height — encoding native (no upscale)"
);
None
}
None => {
tracing::info!(
preset = %quality.label,
max_height = h,
"downscaling to max height (source size unknown — width follows negotiation)"
);
Some(format!(
"{raw_format},height={h},pixel-aspect-ratio=1/1,width=[2,8192,2]"
))
}
}
}
};
// video branch — videorate caps to the target fps so we don't ship at the
// monitor's refresh rate (e.g. 180Hz) and pile up frames in the demuxer
// queue faster than realtime. videoscale (when scaling) runs *after*
// videoconvert so it operates on system-memory NV12/I420: scaling
// pipewiresrc's raw output directly can hit a format/memory (e.g. DMABuf)
// that software videoscale won't negotiate.
args.extend(source.iter().cloned());
args.extend([
"!".into(),
"videorate".into(),
"!".into(),
framerate_caps,
"!".into(),
"queue".into(),
"!".into(),
"videoconvert".into(),
"!".into(),
raw_format.into(),
"!".into(),
]);
if let Some(caps) = scale_caps {
args.extend(["videoscale".into(), "!".into(), caps, "!".into()]);
}
args.extend(encoder_args);
args.extend([
"!".into(),
"h264parse".into(),
"config-interval=-1".into(),
"!".into(),
"video/x-h264,stream-format=byte-stream,alignment=au".into(),
"!".into(),
"mux.".into(),
]);
// audio branch — capture the default sink's MONITOR (system audio out),
// not the default source (which is the mic).
args.extend([
"pulsesrc".into(),
audio_device.to_string(),
"do-timestamp=true".into(),
"!".into(),
"queue".into(),
"!".into(),
"audioconvert".into(),
"!".into(),
"audioresample".into(),
"!".into(),
"audio/x-raw,rate=48000,channels=2".into(),
"!".into(),
"avenc_aac".into(),
"bitrate=128000".into(),
"!".into(),
"aacparse".into(),
"!".into(),
"mux.".into(),
]);
args
}
async fn default_audio_monitor() -> Result<String> {
let output = Command::new("pactl")
.arg("get-default-sink")
.output()
.await
.context("failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)")?;
if !output.status.success() {
bail!(
"pactl get-default-sink failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let sink = String::from_utf8(output.stdout)
.context("default sink name was not UTF-8")?
.trim()
.to_string();
if sink.is_empty() {
bail!("pactl get-default-sink returned no name (is a sound server running?)");
}
Ok(format!("{sink}.monitor"))
}
+257
View File
@@ -0,0 +1,257 @@
//! Resolution / quality presets. A preset bundles `(max_height, bitrate, fps)`
//! because resolution is a *quality-per-bitrate* knob, not a standalone one —
//! the three are only useful together. Quality is **host-global**: one encode
//! pipeline fans out to every viewer over the broadcast channel, so the sharer
//! picks one quality for everyone (per-viewer quality would need per-viewer
//! encodes, which kills the fanout).
//!
//! [`resolve`] turns the raw CLI/picker choice into a concrete
//! [`EffectiveQuality`] the pipeline encodes at, applying — in order — the
//! chosen preset (or an Auto derivation from the bandwidth pre-flight), then
//! any explicit `--bitrate` / `--framerate` / `--max-height` field overrides.
use crate::cli::{HostOpts, Quality};
use crate::common::{config, config::BandwidthStatus};
/// A fixed preset's concrete settings. `max_height = None` means encode at the
/// native source resolution (no `videoscale` element is inserted at all).
#[derive(Debug, Clone, Copy)]
struct Preset {
max_height: Option<u32>,
bitrate: u32, // kbps
framerate: u32,
}
impl Quality {
/// The fixed tuple for a preset. `Auto` returns `None` — it has no fixed
/// values and resolves to one of the others at runtime (see [`resolve_auto`]).
fn preset(self) -> Option<Preset> {
let p = match self {
Quality::Source => Preset { max_height: None, bitrate: 6000, framerate: 30 },
Quality::High => Preset { max_height: Some(1080), bitrate: 4000, framerate: 30 },
Quality::Medium => Preset { max_height: Some(720), bitrate: 2500, framerate: 30 },
Quality::Low => Preset { max_height: Some(480), bitrate: 1000, framerate: 30 },
Quality::Auto => return None,
};
Some(p)
}
fn name(self) -> &'static str {
match self {
Quality::Source => "Source",
Quality::High => "High",
Quality::Medium => "Medium",
Quality::Low => "Low",
Quality::Auto => "Auto",
}
}
}
/// Fixed presets in descending quality order — Auto walks this to find the
/// best one whose per-viewer bitrate fits the measured upstream budget.
const AUTO_LADDER: [Quality; 4] = [Quality::Source, Quality::High, Quality::Medium, Quality::Low];
/// Auto's fallback when there is no usable bandwidth measurement.
const AUTO_FALLBACK: Quality = Quality::Medium;
/// Fully-resolved quality: the concrete values the pipeline will encode at,
/// plus human-readable strings for the host banner.
#[derive(Debug, Clone)]
pub struct EffectiveQuality {
/// `None` = native resolution (omit `videoscale`); `Some(h)` = scale to height `h`.
pub max_height: Option<u32>,
pub bitrate: u32, // kbps
pub framerate: u32,
/// Short label, e.g. `"High"` or `"Auto → Medium"`.
pub label: String,
/// Provenance note for the banner, e.g. `"user-specified"` or
/// `"auto: 8.8 Mbps safe ÷ 1 viewer"`.
pub note: String,
}
impl EffectiveQuality {
/// `WxH-ish / bitrate / fps` summary for the banner. Width is unknown until
/// capture (the source dictates it), so height is shown as `?xN` / `native`.
pub fn dimensions_summary(&self) -> String {
let res = match self.max_height {
Some(h) => format!("{h}p"),
None => "native".to_string(),
};
format!("{res} / {} kbps / {} fps", self.bitrate, self.framerate)
}
}
/// Resolve the host's quality choice into concrete encode settings.
///
/// `sizing_viewers` is the viewer count Auto sizes its budget against (the
/// resolved `--max-viewers` cap, so quality is chosen for the worst case —
/// quality is baked in at capture-spawn and can't drop when viewer #2 joins).
pub fn resolve(opts: &HostOpts, sizing_viewers: u32) -> EffectiveQuality {
// 1. Base preset: a fixed tuple, or an Auto derivation.
let (base, label, base_note) = match opts.quality {
Quality::Auto => resolve_auto(measured_safe_mbps(), sizing_viewers),
q => {
let p = q.preset().expect("non-Auto presets always have a tuple");
(p, q.name().to_string(), "user-specified".to_string())
}
};
let mut eff = EffectiveQuality {
max_height: base.max_height,
bitrate: base.bitrate,
framerate: base.framerate,
label,
note: base_note,
};
// 2. Per-field overrides win over the preset (precedence rule).
let mut overridden = Vec::new();
if let Some(b) = opts.bitrate {
eff.bitrate = b;
overridden.push("bitrate");
}
if let Some(f) = opts.framerate {
eff.framerate = f;
overridden.push("fps");
}
if let Some(h) = opts.max_height {
eff.max_height = Some(h);
overridden.push("max-height");
}
if !overridden.is_empty() {
eff.note = format!("{}; override: {}", eff.note, overridden.join(", "));
}
eff
}
/// Auto: pick the highest preset whose per-viewer bitrate fits the measured
/// safe upstream divided by the viewer count. Falls back to [`AUTO_FALLBACK`]
/// when there's no usable measurement. Pure (no config I/O) so it's testable;
/// [`resolve`] supplies the measurement via [`measured_safe_mbps`].
fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String, String) {
match safe_mbps {
Some(safe_mbps) => {
let n = sizing_viewers.max(1);
let budget_mbps = safe_mbps / n as f64;
let chosen = AUTO_LADDER
.iter()
.copied()
.find(|q| {
let kbps = q.preset().expect("ladder is fixed presets").bitrate;
(kbps as f64) / 1000.0 <= budget_mbps
})
.unwrap_or(Quality::Low);
let preset = chosen.preset().expect("ladder is fixed presets");
(
preset,
format!("Auto → {}", chosen.name()),
format!("auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"),
)
}
None => {
let preset = AUTO_FALLBACK.preset().expect("fallback is a fixed preset");
(
preset,
format!("Auto → {}", AUTO_FALLBACK.name()),
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)".to_string(),
)
}
}
}
/// The saved safe-upstream figure, only when the pre-flight actually measured
/// one. Skipped/failed/unmeasured all return `None` so Auto falls back.
fn measured_safe_mbps() -> Option<f64> {
let cfg = config::load().ok()?;
if cfg.bandwidth.status == BandwidthStatus::Measured {
cfg.bandwidth.upstream_mbps
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{DisplayServerArg, HostOpts};
/// A HostOpts with no overrides, parameterized by quality + max_viewers.
fn opts(quality: Quality, max_viewers: Option<u32>) -> HostOpts {
HostOpts {
window: false,
app: None,
display_server: None::<DisplayServerArg>,
quality,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers,
interactive: false,
}
}
#[test]
fn fixed_presets_pass_through_their_tuple() {
let e = resolve(&opts(Quality::Medium, None), 1);
assert_eq!(e.max_height, Some(720));
assert_eq!(e.bitrate, 2500);
assert_eq!(e.framerate, 30);
assert_eq!(e.label, "Medium");
assert_eq!(e.note, "user-specified");
// Source is the native (no-scale) preset.
assert_eq!(resolve(&opts(Quality::Source, None), 1).max_height, None);
}
#[test]
fn auto_picks_highest_preset_that_fits_budget() {
// Ample upstream, single viewer → Source fits (6 Mbps <= 8.78).
let (p, label, _) = resolve_auto(Some(8.78), 1);
assert_eq!(p.bitrate, 6000);
assert_eq!(label, "Auto → Source");
// 10 Mbps split across 2 viewers = 5 each → Source(6) no, High(4) yes.
let (p, label, _) = resolve_auto(Some(10.0), 2);
assert_eq!(p.bitrate, 4000);
assert_eq!(label, "Auto → High");
// Tight budget falls to the bottom of the ladder, never below Low.
let (p, _, _) = resolve_auto(Some(0.3), 1);
assert_eq!(p.bitrate, 1000); // Low
}
#[test]
fn auto_without_measurement_falls_back_to_medium() {
let (p, label, note) = resolve_auto(None, 1);
assert_eq!(p.bitrate, 2500); // Medium
assert_eq!(p.max_height, Some(720));
assert_eq!(label, "Auto → Medium");
assert!(note.contains("reconfigure"));
}
#[test]
fn explicit_flags_override_preset_fields() {
let mut o = opts(Quality::High, None);
o.bitrate = Some(9000);
o.framerate = Some(60);
let e = resolve(&o, 1);
assert_eq!(e.bitrate, 9000); // override wins
assert_eq!(e.framerate, 60); // override wins
assert_eq!(e.max_height, Some(1080)); // untouched preset field
assert!(e.note.contains("override: bitrate, fps"));
}
#[test]
fn max_height_override_is_rounded_even_and_applies_to_source() {
// Odd override rounds down to even in the pipeline; here we just assert
// the override replaces the (native) Source height with the raw value;
// the even-rounding happens in pipeline::build_args.
let mut o = opts(Quality::Source, None);
o.max_height = Some(900);
let e = resolve(&o, 1);
assert_eq!(e.max_height, Some(900));
assert!(e.note.contains("override: max-height"));
}
}
+21 -194
View File
@@ -1,8 +1,9 @@
//! Wayland capture: ashpd ScreenCast portal → PipeWire fd → gst-launch. //! Wayland capture: ashpd ScreenCast portal → PipeWire fd → `pipewiresrc`.
//! Builds the gst pipeline that produces MPEG-TS on stdout, then hands //! This module owns only the portal handshake and the source-element args;
//! that stdout to [`super::serve::Serve`] which handles the HTTP fanout. //! the shared encode/mux tail, gst spawn, and serving live in
//! [`super::pipeline`].
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result};
use ashpd::{ use ashpd::{
WindowIdentifier, WindowIdentifier,
desktop::{ desktop::{
@@ -11,65 +12,14 @@ use ashpd::{
}, },
}; };
use nix::fcntl::{FcntlArg, FdFlag, fcntl}; use nix::fcntl::{FcntlArg, FdFlag, fcntl};
use nix::sys::signal::{Signal, kill}; use nix::unistd::close;
use nix::unistd::{Pid, close};
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd}; use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::{Child, Command};
use tokio::time::timeout;
use super::audio::Routing; use super::pipeline::{self, CaptureHandle};
use super::serve::Serve; use super::quality::EffectiveQuality;
use crate::cli::HostOpts; use crate::cli::HostOpts;
pub struct CaptureHandle { pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
gst: Option<Child>,
audio: Option<Routing>,
serve: Option<Serve>,
}
impl CaptureHandle {
pub fn local_port(&self) -> u16 {
self.serve
.as_ref()
.expect("serve is always Some until shutdown")
.local_port()
}
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL,
/// unload audio routing (if any), then tear down the serve layer.
/// The serve reader will see EOF on gst stdout and exit on its own;
/// serve.shutdown() is the backstop.
pub async fn shutdown(mut self) {
if let Some(child) = self.gst.as_mut()
&& let Some(pid) = child.id()
{
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
}
if let Some(child) = self.gst.as_mut() {
let _ = timeout(Duration::from_millis(1000), child.wait()).await;
let _ = child.start_kill();
}
if let Some(audio) = self.audio.take() {
audio.shutdown();
}
if let Some(serve) = self.serve.take() {
serve.shutdown().await;
}
}
}
impl Drop for CaptureHandle {
fn drop(&mut self) {
if let Some(child) = self.gst.as_mut() {
let _ = child.start_kill();
}
// Routing's and Serve's own Drop impls handle the rest.
}
}
pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
// 1. Negotiate the screencast session with the portal. // 1. Negotiate the screencast session with the portal.
let proxy = Screencast::new() let proxy = Screencast::new()
.await .await
@@ -108,124 +58,23 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
tracing::info!(node_id, width = w, height = h, "portal handshake complete"); tracing::info!(node_id, width = w, height = h, "portal handshake complete");
// The fd is CLOEXEC by default; the gst child needs to inherit it across // The fd is CLOEXEC by default; the gst child needs to inherit it across
// exec. We then leak it via into_raw_fd so its lifetime spans the spawn, // exec. We then leak it via into_raw_fd so its lifetime spans the spawn,
// and close the parent's copy once gst is running. // and close the parent's copy once gst is running (the pipeline's
// after_spawn hook below).
clear_cloexec(&pw_fd)?; clear_cloexec(&pw_fd)?;
let raw_fd: RawFd = pw_fd.into_raw_fd(); let raw_fd: RawFd = pw_fd.into_raw_fd();
// 2. Spawn gst-launch with the full pipeline: video AND audio captured, let source_args = vec![
// encoded, and muxed into MPEG-TS inside gst. Output goes to stdout, "pipewiresrc".to_string(),
// which the serve layer pipes to its HTTP fanout — no demux/remux, format!("fd={raw_fd}"),
// no codec assumptions. format!("path={node_id}"),
let key_interval = (opts.framerate * 2).to_string(); "do-timestamp=true".to_string(),
let bitrate = opts.bitrate.to_string(); ];
// Audio routing activates when either: pipeline::spawn(opts, quality, Some((w as u32, h as u32)), source_args, move || {
// - `opts.app` is set (per-stream rerouting to a per-PID null-sink), // Parent no longer needs the pipewire fd — gst inherited its own copy.
// - or `PIXELPASS_AUDIO_VIA_NULL_SINK=1` is set (no app filter, just let _ = close(raw_fd);
// captures everything via the null-sink → useful for development
// and dogfooding the loopback path before app filtering is picked).
let routing_requested =
opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
let audio_routing = if routing_requested {
Some(
Routing::start(opts)
.await
.context("audio routing setup failed")?,
)
} else {
None
};
let audio_device = if let Some(r) = &audio_routing {
format!("device={}.monitor", r.sink_name())
} else {
let default = default_audio_monitor().await?;
format!("device={default}")
};
let mut gst_cmd = Command::new("gst-launch-1.0");
gst_cmd
.args([
// muxer + sink
"mpegtsmux",
"name=mux",
"!",
"queue",
"!",
"fdsink",
"fd=1",
// video branch — videorate caps to 30fps so we don't ship at the
// monitor's refresh rate (e.g. 180Hz) and pile up frames in mpv's
// demuxer queue faster than realtime.
"pipewiresrc",
&format!("fd={raw_fd}"),
&format!("path={node_id}"),
"do-timestamp=true",
"!",
"videorate",
"!",
&format!("video/x-raw,framerate={}/1", opts.framerate),
"!",
"queue",
"!",
"videoconvert",
"!",
"video/x-raw,format=NV12",
"!",
"vah264enc",
"rate-control=cbr",
&format!("bitrate={bitrate}"),
&format!("key-int-max={key_interval}"),
"!",
"h264parse",
"config-interval=-1",
"!",
"video/x-h264,stream-format=byte-stream,alignment=au",
"!",
"mux.",
// audio branch — capture the default sink's MONITOR (system audio
// out), not the default source (which is the mic).
"pulsesrc",
&audio_device,
"do-timestamp=true",
"!",
"queue",
"!",
"audioconvert",
"!",
"audioresample",
"!",
"audio/x-raw,rate=48000,channels=2",
"!",
"avenc_aac",
"bitrate=128000",
"!",
"aacparse",
"!",
"mux.",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() {
gst_cmd.env("GST_DEBUG", "3");
}
let mut gst = gst_cmd.spawn().context("failed to spawn gst-launch-1.0")?;
// Parent no longer needs the pipewire fd — gst inherited its own copy.
let _ = close(raw_fd);
let gst_stdout = gst
.stdout
.take()
.context("gst-launch-1.0 stdout pipe unavailable")?;
// 3. Hand stdout to the serve layer, which binds the localhost HTTP
// listener and runs the broadcast fanout.
let serve = Serve::bind(gst_stdout).await?;
Ok(CaptureHandle {
gst: Some(gst),
audio: audio_routing,
serve: Some(serve),
}) })
.await
} }
fn clear_cloexec(fd: &impl AsFd) -> Result<()> { fn clear_cloexec(fd: &impl AsFd) -> Result<()> {
@@ -235,25 +84,3 @@ fn clear_cloexec(fd: &impl AsFd) -> Result<()> {
fcntl(fd.as_fd(), FcntlArg::F_SETFD(flags)).context("F_SETFD on pipewire fd")?; fcntl(fd.as_fd(), FcntlArg::F_SETFD(flags)).context("F_SETFD on pipewire fd")?;
Ok(()) Ok(())
} }
async fn default_audio_monitor() -> Result<String> {
let output = Command::new("pactl")
.arg("get-default-sink")
.output()
.await
.context("failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)")?;
if !output.status.success() {
bail!(
"pactl get-default-sink failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let sink = String::from_utf8(output.stdout)
.context("default sink name was not UTF-8")?
.trim()
.to_string();
if sink.is_empty() {
bail!("pactl get-default-sink returned no name (is a sound server running?)");
}
Ok(format!("{sink}.monitor"))
}
+103
View File
@@ -0,0 +1,103 @@
//! X11 capture: `ximagesrc` → the shared encode/mux tail in [`super::pipeline`].
//! Unlike Wayland there's no portal and no fd hand-off — `ximagesrc` opens its
//! own X connection from `$DISPLAY`. The whole root window is captured by
//! default; `--window` resolves a single window's XID via an `xwininfo`
//! click-picker. The ticket is the access control, so capture starts silently
//! when the first viewer connects (no host-side consent prompt).
use anyhow::{Context, Result, bail};
use tokio::process::Command;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::ConnectionExt;
use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality;
use crate::cli::HostOpts;
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
let xid = if opts.window {
Some(pick_window().await?)
} else {
None
};
// Geometry mirrors Wayland's portal-handshake log line and feeds the
// downscale presets (so they can compute an exact target size). A failure
// here shouldn't abort capture — ximagesrc will surface a real error if the
// X connection is genuinely unusable, and the scaler falls back to a
// height-only negotiation when dims are unknown.
let source_dims = match read_geometry(xid) {
Ok((w, h)) => {
tracing::info!(width = w, height = h, xid = ?xid, "X11 capture geometry");
Some((w as u32, h as u32))
}
Err(e) => {
tracing::warn!("could not read X11 geometry (capture will still try): {e:#}");
None
}
};
let mut source_args = vec![
"ximagesrc".to_string(),
// Full frames (no damage regions) to avoid partial-update artifacts;
// use-damage=true is a later CPU optimization. show-pointer matches
// Wayland's CursorMode::Embedded.
"use-damage=false".to_string(),
"show-pointer=true".to_string(),
];
if let Some(xid) = xid {
source_args.push(format!("xid={xid}"));
}
// X11 has no leaked fd to clean up, so the post-spawn hook is a no-op.
pipeline::spawn(opts, quality, source_dims, source_args, || {}).await
}
/// Run `xwininfo` and let the user click the window they want to share, then
/// parse the `Window id: 0x…` line out of its output. Returns the numeric XID.
async fn pick_window() -> Result<u32> {
eprintln!("[pixelpass] click the window you want to share…");
let output = Command::new("xwininfo")
.output()
.await
.context("failed to run `xwininfo` (install xorg-xwininfo)")?;
if !output.status.success() {
bail!(
"xwininfo failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
// e.g. "xwininfo: Window id: 0x3a00007 \"xterm\""
if let Some((_, rest)) = line.split_once("Window id: ") {
let token = rest.split_whitespace().next().unwrap_or("");
let hex = token.strip_prefix("0x").unwrap_or(token);
if let Ok(xid) = u32::from_str_radix(hex, 16) {
return Ok(xid);
}
}
}
bail!("could not parse a window id from xwininfo output");
}
/// Read pixel dimensions: the selected window's geometry when `--window` was
/// used, otherwise the root window of the screen named by `$DISPLAY`.
fn read_geometry(xid: Option<u32>) -> Result<(u16, u16)> {
let (conn, screen_num) =
x11rb::connect(None).context("could not connect to the X server (is DISPLAY set?)")?;
match xid {
Some(id) => {
let geo = conn
.get_geometry(id)
.context("GetGeometry request failed")?
.reply()
.context("GetGeometry reply failed")?;
Ok((geo.width, geo.height))
}
None => {
let screen = &conn.setup().roots[screen_num];
Ok((screen.width_in_pixels, screen.height_in_pixels))
}
}
}
+40 -1
View File
@@ -3,7 +3,7 @@ use dialoguer::{Input, Select, theme::ColorfulTheme};
use iroh_tickets::endpoint::EndpointTicket; use iroh_tickets::endpoint::EndpointTicket;
use std::str::FromStr; use std::str::FromStr;
use crate::cli::Cli; use crate::cli::{Cli, Quality};
use crate::common::{bandwidth, config}; use crate::common::{bandwidth, config};
use crate::{host, viewer}; use crate::{host, viewer};
@@ -27,6 +27,9 @@ pub async fn run(cli: Cli) -> Result<()> {
if cli.app.is_none() { if cli.app.is_none() {
cli.app = pick_app(&theme)?; cli.app = pick_app(&theme)?;
} }
if cli.quality.is_none() {
cli.quality = Some(pick_quality(&theme)?);
}
host::run(cli.into_host_opts(true)).await host::run(cli.into_host_opts(true)).await
} }
_ => { _ => {
@@ -80,6 +83,42 @@ fn pick_app(theme: &ColorfulTheme) -> Result<Option<String>> {
} }
} }
/// Picker for the encode quality preset. Mirrors [`pick_app`]; bypassed when
/// `--quality` was given on the CLI. Quality is host-global (the same stream
/// fans out to every viewer), so this is the one choice that sets it for all.
fn pick_quality(theme: &ColorfulTheme) -> Result<Quality> {
eprintln!();
eprintln!("Quality");
eprintln!("───────");
eprintln!("Lower presets trade resolution + bitrate for less upload usage.");
eprintln!("The same quality is sent to every viewer.");
eprintln!();
// Order mirrors the labels below; index maps back to a Quality.
let choices = [
Quality::Auto,
Quality::Source,
Quality::High,
Quality::Medium,
Quality::Low,
];
let items = [
"Auto — pick from my measured upstream (recommended)",
"Source — native resolution, 6000 kbps",
"High — up to 1080p, 4000 kbps",
"Medium — up to 720p, 2500 kbps",
"Low — up to 480p, 1000 kbps",
];
let choice = Select::with_theme(theme)
.with_prompt("What quality should the viewer(s) get?")
.items(&items)
.default(0)
.interact()?;
Ok(choices[choice])
}
/// `pixelpass --reconfigure` entry point: unconditionally re-run the /// `pixelpass --reconfigure` entry point: unconditionally re-run the
/// bandwidth pre-flight test, save the result, and return. Used to /// bandwidth pre-flight test, save the result, and return. Used to
/// refresh a stale measurement (e.g. user moved house, changed ISP). /// refresh a stale measurement (e.g. user moved house, changed ISP).
+40 -1
View File
@@ -1,5 +1,7 @@
mod cli; mod cli;
mod common; mod common;
#[cfg(feature = "gui")]
mod gui;
mod host; mod host;
mod interactive; mod interactive;
mod repair; mod repair;
@@ -16,6 +18,24 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
init_tracing(cli.verbose); init_tracing(cli.verbose);
if matches!(cli.output, Some(cli::OutputFormat::Json)) {
common::output::set_json(true);
}
if cli.gui {
#[cfg(feature = "gui")]
{
return gui::run();
}
#[cfg(not(feature = "gui"))]
{
anyhow::bail!(
"this binary was built without GUI support. Rebuild with \
`cargo build --release --features gui` to use --gui."
);
}
}
// libpipewire requires global init before any pw_* call. Idempotent; // libpipewire requires global init before any pw_* call. Idempotent;
// safe to call even when the per-app audio thread never spawns. // safe to call even when the per-app audio thread never spawns.
pipewire::init(); pipewire::init();
@@ -28,6 +48,16 @@ async fn main() -> Result<()> {
return interactive::run_reconfigure().await; return interactive::run_reconfigure().await;
} }
if cli.host {
if cli.ticket.is_some() {
anyhow::bail!(
"--host and a ticket argument are mutually exclusive: --host shares your \
screen, a ticket views someone else's."
);
}
return host::run(cli.into_host_opts(false)).await;
}
match cli.ticket.as_deref() { match cli.ticket.as_deref() {
Some(s) => { Some(s) => {
let ticket: EndpointTicket = s.parse().map_err(|e| { let ticket: EndpointTicket = s.parse().map_err(|e| {
@@ -45,5 +75,14 @@ async fn main() -> Result<()> {
fn init_tracing(verbose: bool) { fn init_tracing(verbose: bool) {
let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" }; let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" };
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default)); let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init(); // Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
// writer to stdout, but with `--output json` stdout carries the JSON event
// stream the `--gui` front-end parses (see `common::output`) — logging there
// interleaves log lines into that stream (corrupting events and starving the
// GUI's stderr-tail diagnostics). Pin it to stderr to honor that contract.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(filter)
.with_target(false)
.init();
} }
+36 -3
View File
@@ -1,11 +1,18 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result, bail};
use iroh::Endpoint; use iroh::Endpoint;
use iroh::endpoint::presets; use iroh::endpoint::presets;
use iroh_tickets::endpoint::EndpointTicket; use iroh_tickets::endpoint::EndpointTicket;
use std::time::Duration;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::cli::ViewerOpts; use crate::cli::ViewerOpts;
use crate::common::{alpn::ALPN, signal}; use crate::common::{alpn::ALPN, output, signal};
/// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in
/// deadline, so an offline host / stale code / unreachable relay otherwise
/// hangs forever with no feedback (the silent "connecting…" failure mode).
/// Matches the host's 15s `online()` cap.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> { pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
let cancel = signal::install_ctrl_c(); let cancel = signal::install_ctrl_c();
@@ -17,12 +24,38 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
let addr = ticket.endpoint_addr().clone(); let addr = ticket.endpoint_addr().clone();
tracing::info!(remote = %addr.id, "connecting to host"); tracing::info!(remote = %addr.id, "connecting to host");
let conn = endpoint.connect(addr, ALPN).await?;
// Bound the connect attempt and let ctrl-c abort it, so the viewer fails
// loud (and the GUI surfaces the error) instead of spinning indefinitely.
let conn = tokio::select! {
_ = cancel.cancelled() => {
tracing::info!("ctrl-c received before the connection was established");
endpoint.close().await;
return Ok(());
}
result = tokio::time::timeout(CONNECT_TIMEOUT, endpoint.connect(addr, ALPN)) => match result {
Ok(Ok(conn)) => conn,
Ok(Err(e)) => {
endpoint.close().await;
bail!("failed to connect to the host: {e:#}");
}
Err(_) => {
endpoint.close().await;
bail!(
"couldn't reach the host within {}s — it may be offline, the share \
code may be stale, or the relay may be unreachable. Check that the \
host is running, then re-copy the code and try again.",
CONNECT_TIMEOUT.as_secs()
);
}
},
};
let (quic_send, quic_recv) = conn.open_bi().await?; let (quic_send, quic_recv) = conn.open_bi().await?;
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?; let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
let url = format!("http://127.0.0.1:{port}"); let url = format!("http://127.0.0.1:{port}");
output::emit(output::Event::Connected { url: &url });
if opts.interactive { if opts.interactive {
let player = crate::interactive::prompt_player()?; let player = crate::interactive::prompt_player()?;