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
19 changed files with 4635 additions and 244 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"] }
toml = "1"
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]
lto = "thin"
codegen-units = 1
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"]
+60 -1
View File
@@ -31,6 +31,9 @@ Working:
- First-run upstream bandwidth pre-flight, persisted to
`~/.config/pixelpass/config.toml` and used to auto-size the default
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 built (deferred, not blocking):
- Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the
@@ -70,6 +73,24 @@ pixelpass <ticket>
# 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
- Linux (Wayland or X11; the backend is autodetected)
@@ -116,6 +137,14 @@ cargo build --release
`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
```
@@ -242,7 +271,37 @@ message and the host keeps running.
For more viewers, drop the per-viewer bitrate: e.g. `pixelpass
--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
+36
View File
@@ -51,3 +51,39 @@ else
echo "$MPV_LOG" | tail -30 | sed 's/^/ /'
exit 1
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 -8
View File
@@ -13,6 +13,12 @@ pub struct Cli {
pub ticket: Option<String>,
// ── 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.
#[arg(long)]
pub window: bool,
@@ -25,13 +31,25 @@ pub struct Cli {
#[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>,
/// Encode bitrate in kbps.
#[arg(long, default_value_t = 6000)]
pub bitrate: u32,
/// Quality preset. Bundles a max video height, bitrate, and framerate.
/// `auto` derives them from the saved bandwidth pre-flight (falls back to
/// `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.
#[arg(long, default_value_t = 30)]
pub framerate: u32,
/// Cap the encoded video height (px); width follows the source aspect.
/// Power-user override — takes precedence over the preset's height.
#[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.
#[arg(long)]
@@ -50,6 +68,17 @@ pub struct Cli {
pub port: u16,
// ── 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.
#[arg(long, short)]
pub verbose: bool,
@@ -71,13 +100,43 @@ pub enum DisplayServerArg {
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)]
pub struct HostOpts {
pub window: bool,
pub app: Option<String>,
pub display_server: Option<DisplayServerArg>,
pub bitrate: u32,
pub framerate: u32,
/// Chosen preset (Auto = derive at startup). Defaults to Auto.
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 max_viewers: Option<u32>,
pub interactive: bool,
@@ -95,8 +154,12 @@ impl Cli {
window: self.window,
app: self.app,
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,
framerate: self.framerate,
max_height: self.max_height,
no_hwencode: self.no_hwencode,
max_viewers: self.max_viewers,
interactive,
+11
View File
@@ -16,6 +16,10 @@ pub fn check_host_binaries(display: DisplayServer, opts: &HostOpts) -> Result<()
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")?;
@@ -136,6 +140,13 @@ fn install_hint_for_gst_element(name: &str) -> String {
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() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-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 deps;
pub mod display;
pub mod output;
pub mod process;
pub mod signal;
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
}
}
+8 -3
View File
@@ -9,12 +9,17 @@ use anyhow::Result;
use crate::cli::HostOpts;
use crate::common::display::DisplayServer;
use crate::host::pipeline::CaptureHandle;
use crate::host::quality::EffectiveQuality;
use crate::host::{wayland, x11};
pub async fn spawn(display: DisplayServer, opts: &HostOpts) -> Result<CaptureHandle> {
pub async fn spawn(
display: DisplayServer,
opts: &HostOpts,
quality: &EffectiveQuality,
) -> Result<CaptureHandle> {
match display {
DisplayServer::Wayland => wayland::start(opts).await,
DisplayServer::X11 => x11::start(opts).await,
DisplayServer::Wayland => wayland::start(opts, quality).await,
DisplayServer::X11 => x11::start(opts, quality).await,
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
}
}
+104 -22
View File
@@ -1,6 +1,7 @@
pub mod audio;
mod capture;
mod pipeline;
mod quality;
mod serve;
mod wayland;
mod x11;
@@ -9,27 +10,36 @@ use anyhow::{Result, bail};
use iroh::endpoint::{Connection, presets};
use iroh::{Endpoint, EndpointAddr};
use iroh_tickets::endpoint::EndpointTicket;
use std::collections::HashMap;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use crate::cli::HostOpts;
use crate::common::{
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, signal,
tunnel,
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output,
signal, tunnel,
};
use self::pipeline::CaptureHandle;
use self::quality::EffectiveQuality;
/// Messages from per-viewer tasks to the capture supervisor.
enum SupervisorMsg {
/// 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
/// 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
/// 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<()> {
@@ -43,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 {
bail!("--max-viewers must be at least 1");
}
@@ -75,11 +93,44 @@ pub async fn run(opts: HostOpts) -> Result<()> {
let relay_only =
EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned());
let ticket = EndpointTicket::new(relay_only);
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string());
print_host_banner(&ticket, display, &opts, &resolution, clipboard_ok);
let ticket_str = ticket.to_string();
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 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;
@@ -127,9 +178,11 @@ async fn handle_peer(
cancel: CancellationToken,
) {
let remote = conn.remote_id();
let id_str = remote.to_string();
let peer_cancel = CancellationToken::new();
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");
return;
}
@@ -150,7 +203,7 @@ async fn handle_peer(
Ok(s) => s,
Err(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;
}
};
@@ -161,7 +214,7 @@ async fn handle_peer(
Ok(t) => t,
Err(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;
}
};
@@ -175,10 +228,13 @@ async fn handle_peer(
_ = cancel.cancelled() => {
tracing::info!(%remote, "cancellation during stream");
}
_ = peer_cancel.cancelled() => {
tracing::info!(%remote, "kicked by host");
}
}
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
@@ -187,27 +243,35 @@ async fn handle_peer(
/// the count is already at the cap.
async fn supervise(
opts: HostOpts,
quality: EffectiveQuality,
display: DisplayServer,
max_viewers: u32,
mut rx: mpsc::Receiver<SupervisorMsg>,
) {
let mut handle: Option<CaptureHandle> = None;
let mut count: u32 = 0;
let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
SupervisorMsg::AddViewer(reply) => {
SupervisorMsg::AddViewer { id, cancel, reply } => {
if count >= max_viewers {
let _ = reply.send(Err(format!(
"host is full ({count} of {max_viewers} viewers connected)"
)));
let reason =
format!("host is full ({count} of {max_viewers} viewers connected)");
output::emit(output::Event::ViewerRefused { reason: &reason });
let _ = reply.send(Err(reason));
continue;
}
if handle.is_none() {
tracing::info!("first viewer arriving — spawning capture");
match capture::spawn(display, &opts).await {
Ok(h) => handle = Some(h),
match capture::spawn(display, &opts, &quality).await {
Ok(h) => {
handle = Some(h);
output::emit(output::Event::Capture {
state: output::CaptureState::Started,
});
}
Err(e) => {
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
continue;
@@ -217,17 +281,30 @@ async fn supervise(
let port = handle.as_ref().expect("handle was just set").local_port();
count += 1;
viewers.insert(id.clone(), cancel);
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");
}
SupervisorMsg::RemoveViewer => {
SupervisorMsg::RemoveViewer { id } => {
viewers.remove(&id);
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");
if count == 0
&& let Some(h) = handle.take()
{
tracing::info!("last viewer left — tearing down capture");
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();
}
}
}
@@ -236,6 +313,9 @@ async fn supervise(
if let Some(h) = handle.take() {
tracing::info!("host shutdown — tearing down capture");
h.shutdown().await;
output::emit(output::Event::Capture {
state: output::CaptureState::Stopped,
});
}
}
@@ -243,6 +323,7 @@ fn print_host_banner(
ticket: &EndpointTicket,
display: DisplayServer,
opts: &HostOpts,
quality: &EffectiveQuality,
resolution: &MaxViewersResolution,
clipboard_ok: bool,
) {
@@ -250,7 +331,8 @@ fn print_host_banner(
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
eprintln!("│ display server : {display:?}");
eprintln!("│ capture : {}", capture_summary(opts));
eprintln!("bitrate / fps : {} kbps @ {} fps", opts.bitrate, opts.framerate);
eprintln!("quality : {} {}", quality.label, quality.dimensions_summary());
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!("");
@@ -302,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 {
return MaxViewersResolution {
value: n,
@@ -313,7 +395,7 @@ fn resolve_max_viewers(opts: &HostOpts) -> MaxViewersResolution {
&& cfg.bandwidth.status == BandwidthStatus::Measured
&& 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 {
value: n,
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
+84 -13
View File
@@ -14,6 +14,7 @@ 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;
@@ -65,16 +66,22 @@ impl Drop for CaptureHandle {
/// 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", …]`). `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.
/// …]` 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);
let args = build_args(&source_args, &audio_device, opts, quality, source_dims);
let mut gst_cmd = Command::new("gst-launch-1.0");
gst_cmd
@@ -135,14 +142,22 @@ async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
}
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
/// video branch (caller's `source` → videorate cap → encoder → h264parse →
/// mux.), then the audio branch (pulsesrc → AAC → mux.). 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) -> Vec<String> {
let key_interval = (opts.framerate * 2).to_string();
let bitrate = opts.bitrate.to_string();
let framerate_caps = format!("video/x-raw,framerate={}/1", opts.framerate);
/// 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 {
(
@@ -178,9 +193,62 @@ fn build_args(source: &[String], audio_device: &str, opts: &HostOpts) -> Vec<Str
"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.
// 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(),
@@ -195,6 +263,9 @@ fn build_args(source: &[String], audio_device: &str, opts: &HostOpts) -> Vec<Str
raw_format.into(),
"!".into(),
]);
if let Some(caps) = scale_caps {
args.extend(["videoscale".into(), "!".into(), caps, "!".into()]);
}
args.extend(encoder_args);
args.extend([
"!".into(),
+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"));
}
}
+3 -2
View File
@@ -16,9 +16,10 @@ use nix::unistd::close;
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality;
use crate::cli::HostOpts;
pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
// 1. Negotiate the screencast session with the portal.
let proxy = Screencast::new()
.await
@@ -69,7 +70,7 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
"do-timestamp=true".to_string(),
];
pipeline::spawn(opts, source_args, move || {
pipeline::spawn(opts, quality, Some((w as u32, h as u32)), source_args, move || {
// Parent no longer needs the pipewire fd — gst inherited its own copy.
let _ = close(raw_fd);
})
+18 -9
View File
@@ -11,22 +11,31 @@ 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) -> Result<CaptureHandle> {
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
let xid = if opts.window {
Some(pick_window().await?)
} else {
None
};
// Geometry is informational (mirrors Wayland's portal-handshake log line);
// a failure here shouldn't abort capture — ximagesrc will surface a real
// error if the X connection is genuinely unusable.
match read_geometry(xid) {
Ok((w, h)) => tracing::info!(width = w, height = h, xid = ?xid, "X11 capture geometry"),
Err(e) => tracing::warn!("could not read X11 geometry (capture will still try): {e:#}"),
}
// 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(),
@@ -41,7 +50,7 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
}
// X11 has no leaked fd to clean up, so the post-spawn hook is a no-op.
pipeline::spawn(opts, source_args, || {}).await
pipeline::spawn(opts, quality, source_dims, source_args, || {}).await
}
/// Run `xwininfo` and let the user click the window they want to share, then
+40 -1
View File
@@ -3,7 +3,7 @@ use dialoguer::{Input, Select, theme::ColorfulTheme};
use iroh_tickets::endpoint::EndpointTicket;
use std::str::FromStr;
use crate::cli::Cli;
use crate::cli::{Cli, Quality};
use crate::common::{bandwidth, config};
use crate::{host, viewer};
@@ -27,6 +27,9 @@ pub async fn run(cli: Cli) -> Result<()> {
if cli.app.is_none() {
cli.app = pick_app(&theme)?;
}
if cli.quality.is_none() {
cli.quality = Some(pick_quality(&theme)?);
}
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
/// bandwidth pre-flight test, save the result, and return. Used to
/// refresh a stale measurement (e.g. user moved house, changed ISP).
+40 -1
View File
@@ -1,5 +1,7 @@
mod cli;
mod common;
#[cfg(feature = "gui")]
mod gui;
mod host;
mod interactive;
mod repair;
@@ -16,6 +18,24 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
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;
// safe to call even when the per-app audio thread never spawns.
pipewire::init();
@@ -28,6 +48,16 @@ async fn main() -> Result<()> {
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() {
Some(s) => {
let ticket: EndpointTicket = s.parse().map_err(|e| {
@@ -45,5 +75,14 @@ async fn main() -> Result<()> {
fn init_tracing(verbose: bool) {
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));
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::presets;
use iroh_tickets::endpoint::EndpointTicket;
use std::time::Duration;
use tokio::net::TcpListener;
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<()> {
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();
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 listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
let port = listener.local_addr()?.port();
let url = format!("http://127.0.0.1:{port}");
output::emit(output::Event::Connected { url: &url });
if opts.interactive {
let player = crate::interactive::prompt_player()?;