Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e39251189 | ||
|
|
c2d04b35ba | ||
|
|
b4be8deb46 | ||
|
|
e8f86b0ac2 | ||
|
|
5d519ede78 | ||
|
|
ccb183219f | ||
|
|
d23848decc | ||
|
|
48f5510699 | ||
|
|
125e44c033 | ||
|
|
57328f740c | ||
|
|
0187bc9bcf | ||
|
|
90e0dc8621 | ||
|
|
0be92f36a5 | ||
|
|
6f0fd088f6 | ||
|
|
e7ded10db8 | ||
|
|
6619bc9b0f | ||
|
|
29d8850bc5 | ||
|
|
8044a42f98 | ||
|
|
7483b9aae8 | ||
|
|
45e5d7ef37 | ||
|
|
cd127a9704 | ||
|
|
0c9d8eb9f9 | ||
|
|
7fa5d410f9 | ||
|
|
8674f907f2 | ||
|
|
25a5b597f7 | ||
|
|
54ebe96ca1 | ||
|
|
a144665f41 | ||
|
|
339a9d49e4 | ||
|
|
8d32ded412 | ||
|
|
9625511c65 | ||
|
|
e1a018fdf7 | ||
|
|
f939441e31 | ||
|
|
153febe078 | ||
|
|
ffe5a90686 | ||
|
|
74b4101d4f | ||
|
|
6e4d30bfa9 | ||
|
|
15766834f1 | ||
|
|
0a253bd919 | ||
|
|
3aa8d73ea0 | ||
|
|
8619df10d5 |
Generated
+2672
-180
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -2,7 +2,7 @@
|
||||
name = "pixelpass"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "P2P screen sharing CLI over iroh + ffmpeg"
|
||||
description = "P2P screen sharing CLI over iroh"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
@@ -30,8 +30,20 @@ uuid = { version = "1", features = ["v4"] }
|
||||
iroh-tickets = "1.0.0-rc.0"
|
||||
dialoguer = { version = "0.12", default-features = false }
|
||||
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
# pixelpass
|
||||
|
||||
P2P screen sharing CLI for Linux. Single binary, hole-punched over
|
||||
[iroh](https://www.iroh.computer/) — no port forwarding, no signup, no
|
||||
server-side accounts. Hardware-encoded H.264 + AAC audio, viewed in
|
||||
mpv or VLC.
|
||||
|
||||
Built for people who just want to show their screen to a friend
|
||||
without spinning up a Discord call or fighting with NAT.
|
||||
|
||||
## Status
|
||||
|
||||
**v0.1.0** — verified end-to-end on the public internet (LTE relay path,
|
||||
~2s latency, real carrier-grade NAT) as of 2026-05-20.
|
||||
|
||||
Working:
|
||||
- Wayland capture via the screencast portal (KDE Plasma 6 confirmed; other
|
||||
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
|
||||
GPUs should work), with a software x264 fallback via `--no-hwencode`
|
||||
- Audio capture of the default sink's monitor, with optional per-app
|
||||
routing (`--app <name>`)
|
||||
- `--repair` cleanup of orphaned PipeWire state left by a crashed host
|
||||
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
|
||||
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
|
||||
- Headless mode for scripts (`pixelpass <ticket>`)
|
||||
- Multi-viewer fanout (default 2, configurable via `--max-viewers`;
|
||||
shared gst pipeline, one broadcast channel per host)
|
||||
- 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
|
||||
whole root canvas; single-monitor cropping needs xrandr region coords
|
||||
- `use-damage=true` CPU optimization for the X11 capture path
|
||||
|
||||
## Quick start
|
||||
|
||||
### Interactive (recommended)
|
||||
|
||||
```sh
|
||||
pixelpass
|
||||
```
|
||||
|
||||
On the host machine: pick "Host", share a monitor via the portal dialog,
|
||||
the ticket lands on your clipboard. Send it to your viewer however you
|
||||
like (chat, email, paste in a note). The same ticket works for multiple
|
||||
viewers up to your `--max-viewers` cap.
|
||||
|
||||
The very first host launch offers a one-time upstream bandwidth test
|
||||
(~5 s, ~5 MB to Cloudflare's open speed-test endpoint) so it can pick
|
||||
a sensible default for the viewer cap. You can skip it and a
|
||||
conservative default (2 viewers) is used; re-run it later with
|
||||
`pixelpass --reconfigure`.
|
||||
|
||||
On the viewer machine: run `pixelpass`, pick "View", paste the ticket,
|
||||
pick mpv or VLC. The player launches detached and the stream starts.
|
||||
|
||||
### Headless
|
||||
|
||||
```sh
|
||||
# host: prints a ticket on stdout, waits for a peer
|
||||
pixelpass
|
||||
|
||||
# viewer: skips the menu
|
||||
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)
|
||||
- A VAAPI-capable GPU and the right driver:
|
||||
- AMD: `libva-mesa-driver`
|
||||
- Intel: `intel-media-driver` (modern iGPUs) or `intel-vaapi-driver` (older)
|
||||
- NVIDIA: `libva-nvidia-driver` (untested)
|
||||
- `vainfo` from `libva-utils` should list at least one H.264 entrypoint
|
||||
- GStreamer with these plugin packages installed:
|
||||
- `gstreamer`, `gst-plugins-base`, `gst-plugins-good`, `gst-plugins-bad`,
|
||||
`gst-plugins-ugly`, `gst-libav`, `gst-plugin-va`, `gst-plugin-pipewire`
|
||||
- A player: `mpv` (recommended) or `vlc`
|
||||
- If you use VLC, two split plugin packages are also needed on Arch-family
|
||||
distros — the base `vlc` package does not pull them in:
|
||||
- `vlc-plugin-dvb` — provides the MPEG-TS demuxer (`libts_plugin.so`).
|
||||
Without it, VLC can't parse the container.
|
||||
- `vlc-plugin-ffmpeg` — provides the H.264 decoder
|
||||
(`libavcodec_plugin.so`). Without it, VLC parses the container,
|
||||
identifies the codec as H.264, then errors with
|
||||
`Codec h264 ... is not supported`.
|
||||
mpv ships its own decoder stack and doesn't share either dependency.
|
||||
- PipeWire (for screencast portal + audio capture)
|
||||
|
||||
On Arch / CachyOS / EndeavourOS:
|
||||
|
||||
```sh
|
||||
sudo pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad \
|
||||
gst-plugins-ugly gst-libav gst-plugin-va gst-plugin-pipewire \
|
||||
libva-utils mpv
|
||||
# plus your GPU's VAAPI driver
|
||||
# plus, if you want to use VLC instead of mpv:
|
||||
sudo pacman -S vlc vlc-plugin-dvb vlc-plugin-ffmpeg
|
||||
```
|
||||
|
||||
If the viewer is running on battery, set the CPU governor to performance
|
||||
or balanced — power-saver can choke even hardware-decoded 1080p H.264.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/pixelpass --help
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
```
|
||||
Host Viewer
|
||||
──── ──────
|
||||
Wayland portal (ashpd) ──> PipeWire fd ─┐ (X11: ximagesrc, no portal)
|
||||
│
|
||||
▼
|
||||
gst-launch: <source> -> videorate -> vah264enc/x264enc ->
|
||||
h264parse -> mpegtsmux
|
||||
(audio: pulsesrc <sink>.monitor ->
|
||||
avenc_aac -> aacparse ─┘)
|
||||
│ stdout
|
||||
▼
|
||||
tokio HTTP server (in-process, ~30 lines)
|
||||
│
|
||||
▼
|
||||
iroh QUIC bi-stream (ALPN pixelpass/0) ◄══════════►
|
||||
│
|
||||
▼
|
||||
tokio TcpListener
|
||||
on 127.0.0.1:rand
|
||||
│
|
||||
▼
|
||||
mpv / VLC HTTP client
|
||||
```
|
||||
|
||||
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
|
||||
other end and streams GStreamer's stdout (an MPEG-TS containing
|
||||
H.264 + AAC) through with no demux or remux.
|
||||
|
||||
iroh handles NAT traversal: direct UDP if hole-punching succeeds,
|
||||
relay path otherwise. Both have been verified end-to-end.
|
||||
|
||||
## Why these choices
|
||||
|
||||
- **iroh over Holesail / dumbpipe / Tailscale**: single Rust dep, no Node
|
||||
runtime, no signup, no daemon — fits the "one self-contained binary"
|
||||
goal.
|
||||
- **GStreamer for capture/encode, not ffmpeg**: stride/format pitfalls
|
||||
when bridging raw video between processes; one in-process pipeline
|
||||
sidesteps them.
|
||||
- **In-process Rust HTTP server, not ffmpeg-as-server**: ffmpeg's
|
||||
`-listen 1` is one-shot and probe-budget-sensitive; the Rust task is
|
||||
pure passthrough with no codec assumptions.
|
||||
- **MPEG-TS over fragmented MP4**: every player on Linux handles it
|
||||
out of the box. AV1-in-MPEG-TS was tried and is unworkable through
|
||||
libavformat — if AV1 ever comes back, it has to ride a different
|
||||
container.
|
||||
- **VAAPI H.264 over x264**: ~5% of one CPU core instead of ~50% on
|
||||
the host's hardware.
|
||||
|
||||
## Configuration
|
||||
|
||||
`pixelpass` keeps a small TOML config at `~/.config/pixelpass/config.toml`
|
||||
(or the XDG equivalent). Right now it only stores the result of the
|
||||
bandwidth pre-flight:
|
||||
|
||||
```toml
|
||||
[bandwidth]
|
||||
status = "measured" # measured | skipped | failed | unmeasured
|
||||
upstream_mbps = 8.78 # safe estimate (raw * 0.8)
|
||||
measured_at = "2026-05-21T20:41:16Z"
|
||||
```
|
||||
|
||||
- `pixelpass --reconfigure` re-runs the test (e.g. after an ISP change).
|
||||
- Deleting the file resets pixelpass to first-run state.
|
||||
- Skip is sticky — once you skip the test, pixelpass won't ask again
|
||||
unless you reconfigure.
|
||||
|
||||
## Audio
|
||||
|
||||
By default pixelpass captures the default sink's monitor — the viewer
|
||||
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.
|
||||
|
||||
Microphone capture is intentionally 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.
|
||||
|
||||
If a host crashes mid-session it can leave orphaned `pixelpass_capture_*`
|
||||
null-sinks and their paired loopbacks loaded in PipeWire. Run
|
||||
`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
|
||||
|
||||
One gst capture pipeline fans out to N concurrent viewers via a
|
||||
`tokio::sync::broadcast` channel. The same ticket is reusable: as long
|
||||
as a viewer is connected, capture stays alive; when the last one
|
||||
leaves, the pipeline tears down and the portal stops streaming. A new
|
||||
viewer connecting after that re-triggers the portal dialog.
|
||||
|
||||
Capacity is bounded by upstream bandwidth (each viewer is its own
|
||||
encrypted egress). The default cap comes from the bandwidth pre-flight
|
||||
result; `--max-viewers <N>` overrides it. When the cap is hit,
|
||||
additional connections are politely refused with a "host is full"
|
||||
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. 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
|
||||
|
||||
- **VLC needs `vlc-plugin-dvb` and `vlc-plugin-ffmpeg`** on Arch-family
|
||||
distros — the base `vlc` package doesn't pull these in, and missing
|
||||
either one breaks playback (the first kills the demuxer, the second
|
||||
kills the H.264 decoder). pixelpass warns at player-launch time if
|
||||
either plugin isn't on disk. mpv doesn't share these dependencies.
|
||||
- **Audio echo** if the host plays the stream through speakers and
|
||||
captures system audio — expected, the mic / monitor picks up the
|
||||
playback. Headphones bypass it.
|
||||
- **Late joiners see ~2 s of garbage** before the next keyframe lets
|
||||
their decoder lock. Expected behavior, not a bug.
|
||||
- **VAAPI driver must be package-tracked**, not an orphaned `.so` on
|
||||
disk. mpv's `--hwdec=auto` silently falls back to software decode
|
||||
otherwise, which then chokes on a low-power viewer.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0, your pick.
|
||||
@@ -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
|
||||
|
||||
+85
-19
@@ -4,7 +4,7 @@ use clap::{Parser, ValueEnum};
|
||||
#[command(
|
||||
name = "pixelpass",
|
||||
version,
|
||||
about = "P2P screen sharing over iroh + ffmpeg",
|
||||
about = "P2P screen sharing over iroh",
|
||||
long_about = "Run with no arguments for an interactive Host/View menu. \
|
||||
Pass a ticket positionally to skip the menu and view headlessly."
|
||||
)]
|
||||
@@ -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,
|
||||
@@ -21,29 +27,40 @@ pub struct Cli {
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub app: Option<String>,
|
||||
|
||||
/// Mix in the default microphone source.
|
||||
#[arg(long)]
|
||||
pub mic: bool,
|
||||
|
||||
/// Override display server autodetection.
|
||||
#[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)]
|
||||
pub no_hwencode: bool,
|
||||
|
||||
/// Use low-latency SRT transport instead of HTTP MPEG-TS (Phase 2/3).
|
||||
/// Maximum number of concurrent viewers. Additional connections are
|
||||
/// politely refused with a "host full" message. Defaults to the
|
||||
/// connection-aware recommendation from the bandwidth pre-flight if
|
||||
/// available, otherwise 2.
|
||||
#[arg(long)]
|
||||
pub low_latency: bool,
|
||||
pub max_viewers: Option<u32>,
|
||||
|
||||
// ── viewer options ────────────────────────────────────────────────
|
||||
/// Local TCP port for the viewer to expose (default: random).
|
||||
@@ -51,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,
|
||||
@@ -58,6 +86,12 @@ pub struct Cli {
|
||||
/// Clean up orphaned PipeWire state from a crashed host run, then exit.
|
||||
#[arg(long)]
|
||||
pub repair: bool,
|
||||
|
||||
/// Re-run the bandwidth pre-flight test, save the result, then exit.
|
||||
/// Use this if your connection has changed (new ISP, moved house, etc.)
|
||||
/// or if the previously saved test result is stale.
|
||||
#[arg(long)]
|
||||
pub reconfigure: bool,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Clone, Copy, Debug)]
|
||||
@@ -66,16 +100,45 @@ 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 mic: bool,
|
||||
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 low_latency: bool,
|
||||
pub max_viewers: Option<u32>,
|
||||
pub interactive: bool,
|
||||
}
|
||||
|
||||
@@ -90,12 +153,15 @@ impl Cli {
|
||||
HostOpts {
|
||||
window: self.window,
|
||||
app: self.app,
|
||||
mic: self.mic,
|
||||
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,
|
||||
low_latency: self.low_latency,
|
||||
max_viewers: self.max_viewers,
|
||||
interactive,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! One-shot upstream bandwidth measurement against Cloudflare's open
|
||||
//! speed-test endpoint. POST a fixed payload, time it, derive Mbps.
|
||||
//!
|
||||
//! Run via `tokio::task::spawn_blocking` from async contexts — ureq is a
|
||||
//! blocking client and we don't want to wedge the tokio runtime during
|
||||
//! the test.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const ENDPOINT: &str = "https://speed.cloudflare.com/__up";
|
||||
const PAYLOAD_BYTES: usize = 5 * 1024 * 1024; // 5 MiB
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Multiplier applied to the raw measurement. TCP slow-start, ramp-up, and
|
||||
/// real-world contention all mean a one-shot upstream test slightly
|
||||
/// overestimates sustainable throughput; clamp to 80% for headroom.
|
||||
const SAFETY_FACTOR: f64 = 0.80;
|
||||
|
||||
/// Result of a successful measurement.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Measurement {
|
||||
/// Raw measured throughput in megabits per second.
|
||||
pub raw_mbps: f64,
|
||||
/// `raw_mbps * SAFETY_FACTOR` — the value to use when sizing things.
|
||||
pub safe_mbps: f64,
|
||||
/// How long the upload took.
|
||||
pub elapsed: Duration,
|
||||
}
|
||||
|
||||
/// Blocking upload-speed test. Call from a `spawn_blocking` task.
|
||||
pub fn measure_upstream_blocking() -> Result<Measurement> {
|
||||
let payload = vec![0u8; PAYLOAD_BYTES];
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(HTTP_TIMEOUT))
|
||||
.build()
|
||||
.new_agent();
|
||||
|
||||
let start = Instant::now();
|
||||
let response = agent
|
||||
.post(ENDPOINT)
|
||||
.content_type("application/octet-stream")
|
||||
.send(&payload[..])
|
||||
.context("upload request to Cloudflare failed")?;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Cloudflare returned HTTP {status}");
|
||||
}
|
||||
|
||||
let bits = (PAYLOAD_BYTES as f64) * 8.0;
|
||||
let seconds = elapsed.as_secs_f64().max(0.001);
|
||||
let raw_mbps = bits / seconds / 1_000_000.0;
|
||||
let safe_mbps = raw_mbps * SAFETY_FACTOR;
|
||||
|
||||
Ok(Measurement {
|
||||
raw_mbps,
|
||||
safe_mbps,
|
||||
elapsed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a safe-upstream Mbps figure plus the host's per-viewer bitrate
|
||||
/// (kbps for video, ignoring audio + protocol overhead which we account for
|
||||
/// via SAFETY_FACTOR) into a recommended viewer count. Floors to at least 1.
|
||||
pub fn recommended_max_viewers(safe_mbps: f64, bitrate_kbps: u32) -> u32 {
|
||||
let per_viewer_mbps = (bitrate_kbps as f64) / 1000.0;
|
||||
if per_viewer_mbps <= 0.0 {
|
||||
return 1;
|
||||
}
|
||||
let n = (safe_mbps / per_viewer_mbps).floor();
|
||||
if n < 1.0 { 1 } else { n as u32 }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Persistent user-level config at `~/.config/pixelpass/config.toml`.
|
||||
//!
|
||||
//! Right now this only tracks the bandwidth pre-flight result. Future
|
||||
//! preferences (default player, default bitrate, etc.) can hang off the
|
||||
//! same file under their own `[section]`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub bandwidth: BandwidthEntry,
|
||||
}
|
||||
|
||||
/// Result of the first-run upstream measurement.
|
||||
///
|
||||
/// `status = "unmeasured"` means we've never asked the user — show the
|
||||
/// first-run dialog. `"measured"` means we have a number. `"skipped"`
|
||||
/// means the user opted out (sticky — don't ask again). `"failed"`
|
||||
/// means the last attempt errored and we should ask the user on next
|
||||
/// interactive launch whether to retry.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BandwidthEntry {
|
||||
#[serde(default = "default_status")]
|
||||
pub status: BandwidthStatus,
|
||||
#[serde(default)]
|
||||
pub upstream_mbps: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub measured_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BandwidthStatus {
|
||||
Unmeasured,
|
||||
Measured,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Default for BandwidthStatus {
|
||||
fn default() -> Self {
|
||||
Self::Unmeasured
|
||||
}
|
||||
}
|
||||
|
||||
fn default_status() -> BandwidthStatus {
|
||||
BandwidthStatus::Unmeasured
|
||||
}
|
||||
|
||||
/// Returns `~/.config/pixelpass/config.toml` (or the XDG equivalent on other
|
||||
/// platforms). The parent directory is created lazily by [`save`].
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
let dirs = ProjectDirs::from("", "", "pixelpass")
|
||||
.context("could not locate a config directory for pixelpass")?;
|
||||
Ok(dirs.config_dir().join("config.toml"))
|
||||
}
|
||||
|
||||
/// Returns the loaded config, or a `Default` instance if the file doesn't
|
||||
/// exist yet. Bubble up parse errors so we don't silently overwrite a
|
||||
/// hand-edited config the user is debugging.
|
||||
pub fn load() -> Result<Config> {
|
||||
let path = config_path()?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => toml::from_str::<Config>(&s)
|
||||
.with_context(|| format!("failed to parse {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomic write via tempfile-in-same-dir + rename.
|
||||
pub fn save(cfg: &Config) -> Result<()> {
|
||||
let path = config_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("config path has no parent directory")?;
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let serialized =
|
||||
toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?;
|
||||
|
||||
let tmp = parent.join(format!(".config.toml.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
f.write_all(serialized.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
+68
-12
@@ -2,21 +2,49 @@ use anyhow::{Result, bail};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
use crate::common::display::DisplayServer;
|
||||
|
||||
pub fn check_host_binaries(display: DisplayServer) -> Result<()> {
|
||||
if display == DisplayServer::Wayland {
|
||||
require("gst-launch-1.0")?;
|
||||
require("gst-inspect-1.0")?;
|
||||
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")?;
|
||||
pub fn check_host_binaries(display: DisplayServer, opts: &HostOpts) -> Result<()> {
|
||||
// Unknown is handled (and rejected) by the caller; nothing to check here.
|
||||
if display == DisplayServer::Unknown {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -69,6 +97,13 @@ fn install_hint_for_bin(bin: &str) -> String {
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils",
|
||||
_ => "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,
|
||||
};
|
||||
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",
|
||||
_ => "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() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod alpn;
|
||||
pub mod bandwidth;
|
||||
pub mod config;
|
||||
pub mod deps;
|
||||
pub mod display;
|
||||
pub mod output;
|
||||
pub mod process;
|
||||
pub mod signal;
|
||||
pub mod tunnel;
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Per-app audio routing.
|
||||
//!
|
||||
//! Two cooperating layers:
|
||||
//!
|
||||
//! - **Null-sink + loopback** (pactl shell-out): a per-PID null-sink
|
||||
//! `pixelpass_capture_<pid>` plus a `module-loopback` that mirrors the
|
||||
//! default sink's monitor into it. gst captures from the null-sink's
|
||||
//! monitor, so the viewer hears whatever the user hears — by default.
|
||||
//!
|
||||
//! - **Per-stream rerouting** (libpipewire on a dedicated OS thread):
|
||||
//! when [`HostOpts::app`] is set, a [`StreamRouter`] subscribes to the
|
||||
//! PipeWire registry, finds `Stream/Output/Audio` nodes whose
|
||||
//! `application.name` matches the filter, and writes
|
||||
//! `target.object` to the "default" metadata so WirePlumber reroutes
|
||||
//! them to our null-sink. Once at least one stream is actually routed,
|
||||
//! the loopback is unloaded — otherwise the viewer would hear the
|
||||
//! filtered audio twice (once via the routed stream, once via the
|
||||
//! default-sink monitor loopback).
|
||||
//!
|
||||
//! pactl is the right tool for the one-shot null-sink/loopback graph
|
||||
//! mutations. libpipewire is dragged in only when per-stream filtering
|
||||
//! is requested, because that needs registry-event subscription.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::process::Command;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
/// Owns the pactl-loaded modules plus, when filtering is active, the
|
||||
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
|
||||
/// prefer [`Routing::shutdown`] explicitly so failures get logged.
|
||||
pub struct Routing {
|
||||
sink_module: Option<u32>,
|
||||
/// Shared with the event task so it can `take()` and unload on the
|
||||
/// first successful route. `Routing::shutdown` unloads whatever
|
||||
/// remains.
|
||||
loopback_module: Arc<Mutex<Option<u32>>>,
|
||||
sink_name: String,
|
||||
stream_router: Option<StreamRouter>,
|
||||
event_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Routing {
|
||||
/// Create the per-PID null-sink + loopback. If `opts.app` is set,
|
||||
/// also spawn the libpipewire thread that reroutes matching streams.
|
||||
pub async fn start(opts: &HostOpts) -> Result<Self> {
|
||||
let pid = std::process::id();
|
||||
let sink_name = format!("pixelpass_capture_{pid}");
|
||||
|
||||
let sink_module =
|
||||
load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
|
||||
.context("failed to load module-null-sink")?;
|
||||
|
||||
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
||||
// default of 200ms is enough to be perceptible.
|
||||
let loopback_module = load_module(&[
|
||||
"module-loopback",
|
||||
"source=@DEFAULT_SINK@.monitor",
|
||||
&format!("sink={sink_name}"),
|
||||
"latency_msec=20",
|
||||
])
|
||||
.context("failed to load module-loopback (null-sink will be cleaned up on Drop)")?;
|
||||
|
||||
tracing::info!(
|
||||
sink_module,
|
||||
loopback_module,
|
||||
%sink_name,
|
||||
"audio routing: null-sink + loopback ready"
|
||||
);
|
||||
|
||||
let loopback_arc = Arc::new(Mutex::new(Some(loopback_module)));
|
||||
let mut routing = Self {
|
||||
sink_module: Some(sink_module),
|
||||
loopback_module: Arc::clone(&loopback_arc),
|
||||
sink_name: sink_name.clone(),
|
||||
stream_router: None,
|
||||
event_task: None,
|
||||
};
|
||||
|
||||
if let Some(app) = &opts.app {
|
||||
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
|
||||
let loopback_for_task = Arc::clone(&loopback_arc);
|
||||
let sink_name_for_task = sink_name.clone();
|
||||
let event_task = tokio::spawn(async move {
|
||||
while let Some(ev) = event_rx.recv().await {
|
||||
match ev {
|
||||
Event::FirstRoutedStream => {
|
||||
let mid = loopback_for_task.lock().unwrap().take();
|
||||
if let Some(id) = mid {
|
||||
tracing::info!(
|
||||
"audio routing: first stream routed → unloading default-sink loopback"
|
||||
);
|
||||
unload_module(id);
|
||||
}
|
||||
}
|
||||
Event::LastRoutedStreamGone => {
|
||||
// Routed app exited mid-session. Restore the
|
||||
// default-sink loopback so the viewer hears
|
||||
// system audio again instead of silence.
|
||||
if loopback_for_task.lock().unwrap().is_some() {
|
||||
continue;
|
||||
}
|
||||
tracing::info!(
|
||||
"audio routing: last routed stream gone → restoring default-sink loopback"
|
||||
);
|
||||
match load_module(&[
|
||||
"module-loopback",
|
||||
"source=@DEFAULT_SINK@.monitor",
|
||||
&format!("sink={sink_name_for_task}"),
|
||||
"latency_msec=20",
|
||||
]) {
|
||||
Ok(id) => {
|
||||
*loopback_for_task.lock().unwrap() = Some(id);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"audio routing: failed to re-load loopback: {e:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
routing.stream_router = Some(router);
|
||||
routing.event_task = Some(event_task);
|
||||
}
|
||||
|
||||
Ok(routing)
|
||||
}
|
||||
|
||||
pub fn sink_name(&self) -> &str {
|
||||
&self.sink_name
|
||||
}
|
||||
|
||||
/// Stop the stream router (if any), then unload loopback (if still
|
||||
/// loaded), then unload the null-sink. Order matters: PipeWire can
|
||||
/// leave zombie links if you destroy a sink with active inputs.
|
||||
pub fn shutdown(mut self) {
|
||||
if let Some(router) = self.stream_router.take() {
|
||||
router.shutdown();
|
||||
}
|
||||
if let Some(task) = self.event_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(id) = self.loopback_module.lock().unwrap().take() {
|
||||
unload_module(id);
|
||||
}
|
||||
if let Some(id) = self.sink_module.take() {
|
||||
unload_module(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Routing {
|
||||
fn drop(&mut self) {
|
||||
if let Some(router) = self.stream_router.take() {
|
||||
router.shutdown();
|
||||
}
|
||||
if let Some(task) = self.event_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(id) = self.loopback_module.lock().unwrap().take() {
|
||||
unload_module(id);
|
||||
}
|
||||
if let Some(id) = self.sink_module.take() {
|
||||
unload_module(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// App enumeration (interactive picker source)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One deduplicated app currently producing audio. The picker in
|
||||
/// interactive mode shows these as the per-app capture choices.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct App {
|
||||
pub name: String,
|
||||
pub stream_count: u32,
|
||||
}
|
||||
|
||||
/// Enumerate apps currently sending audio to any sink, deduplicated by
|
||||
/// `application.name`. Returns an empty Vec if nothing is playing.
|
||||
pub fn list_playing_apps() -> Result<Vec<App>> {
|
||||
let output = Command::new("pactl")
|
||||
.args(["-f", "json", "list", "sink-inputs"])
|
||||
.output()
|
||||
.context("failed to run `pactl -f json list sink-inputs`")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl list sink-inputs failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
parse_sink_inputs(&output.stdout)
|
||||
}
|
||||
|
||||
fn parse_sink_inputs(stdout: &[u8]) -> Result<Vec<App>> {
|
||||
let entries: Vec<SinkInput> =
|
||||
serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?;
|
||||
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let Some(name) = entry.properties.application_name else { continue };
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
*counts.entry(trimmed.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
Ok(counts
|
||||
.into_iter()
|
||||
.map(|(name, stream_count)| App { name, stream_count })
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SinkInput {
|
||||
properties: SinkInputProperties,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SinkInputProperties {
|
||||
#[serde(rename = "application.name")]
|
||||
application_name: Option<String>,
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// pactl module helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn load_module(args: &[&str]) -> Result<u32> {
|
||||
let output = Command::new("pactl")
|
||||
.arg("load-module")
|
||||
.args(args)
|
||||
.output()
|
||||
.context("failed to run pactl load-module")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl load-module failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let id_str = String::from_utf8(output.stdout)
|
||||
.context("pactl returned non-UTF-8")?
|
||||
.trim()
|
||||
.to_string();
|
||||
id_str
|
||||
.parse::<u32>()
|
||||
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
|
||||
}
|
||||
|
||||
fn unload_module(id: u32) {
|
||||
let result = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(id.to_string())
|
||||
.output();
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
tracing::info!(module = id, "audio routing: unloaded pactl module");
|
||||
}
|
||||
Ok(output) => {
|
||||
tracing::warn!(
|
||||
module = id,
|
||||
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
|
||||
"audio routing: pactl unload-module exited non-zero"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
module = id,
|
||||
"audio routing: failed to run pactl unload-module: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Per-stream routing (libpipewire thread)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Command from tokio → libpipewire thread.
|
||||
enum Cmd {
|
||||
/// Clear `target.object` for everything we routed, then quit the
|
||||
/// MainLoop so the thread joins.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Event from libpipewire thread → tokio. The pair drives loopback
|
||||
/// oscillation: unload on `FirstRoutedStream`, re-load on
|
||||
/// `LastRoutedStreamGone`. Both fire on count-transitions (0→N and N→0
|
||||
/// respectively), not on every change.
|
||||
enum Event {
|
||||
/// At least one stream is now routed to our sink. Receiver unloads
|
||||
/// the default-sink loopback so the filtered audio isn't doubled.
|
||||
FirstRoutedStream,
|
||||
/// The last routed stream just disappeared (app closed, paused,
|
||||
/// switched output). Receiver re-loads the default-sink loopback so
|
||||
/// the viewer doesn't go silent.
|
||||
LastRoutedStreamGone,
|
||||
}
|
||||
|
||||
/// Handle to the libpipewire stream-router thread.
|
||||
pub struct StreamRouter {
|
||||
cmd_tx: pipewire::channel::Sender<Cmd>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl StreamRouter {
|
||||
/// Spawn the libpipewire thread. Returns the router handle and the
|
||||
/// event receiver tokio side polls.
|
||||
fn spawn(
|
||||
filter_name: String,
|
||||
sink_name: String,
|
||||
) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver<Event>)> {
|
||||
let (cmd_tx, cmd_rx) = pipewire::channel::channel::<Cmd>();
|
||||
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pixelpass-pw-router".to_string())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_router(filter_name, sink_name, cmd_rx, event_tx) {
|
||||
tracing::warn!("audio routing: libpipewire thread exited with error: {e:#}");
|
||||
}
|
||||
})
|
||||
.context("failed to spawn libpipewire router thread")?;
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
cmd_tx,
|
||||
thread: Some(thread),
|
||||
},
|
||||
event_rx,
|
||||
))
|
||||
}
|
||||
|
||||
fn shutdown(mut self) {
|
||||
// Best-effort: if the send fails the thread is already gone.
|
||||
let _ = self.cmd_tx.send(Cmd::Shutdown);
|
||||
if let Some(t) = self.thread.take()
|
||||
&& let Err(e) = t.join()
|
||||
{
|
||||
tracing::warn!("audio routing: pw thread join failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of the libpipewire thread. Owns MainLoop, registry listener, and
|
||||
/// all PipeWire proxies for the duration of the routing session.
|
||||
fn run_router(
|
||||
filter_name: String,
|
||||
sink_name: String,
|
||||
cmd_rx: pipewire::channel::Receiver<Cmd>,
|
||||
event_tx: tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
) -> Result<()> {
|
||||
use pipewire::{self as pw, types::ObjectType};
|
||||
|
||||
let main_loop = pw::main_loop::MainLoopRc::new(None)
|
||||
.context("pw main loop construction failed")?;
|
||||
let context = pw::context::ContextRc::new(&main_loop, None)
|
||||
.context("pw context construction failed")?;
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.context("pw core connect failed (is the daemon running?)")?;
|
||||
let registry = core
|
||||
.get_registry_rc()
|
||||
.context("pw get_registry failed")?;
|
||||
|
||||
let state = Rc::new(RefCell::new(RouterState {
|
||||
sink_serial: None,
|
||||
default_metadata: None,
|
||||
routed_node_ids: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
}));
|
||||
|
||||
// Cmd handler: clear metadata for routed streams, then quit.
|
||||
let main_loop_for_cmd = main_loop.clone();
|
||||
let state_for_cmd = Rc::clone(&state);
|
||||
let _cmd_recv = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
|
||||
Cmd::Shutdown => {
|
||||
let s = state_for_cmd.borrow();
|
||||
if let Some(meta) = &s.default_metadata {
|
||||
for &nid in &s.routed_node_ids {
|
||||
meta.set_property(nid, "target.object", None, None);
|
||||
}
|
||||
if !s.routed_node_ids.is_empty() {
|
||||
tracing::info!(
|
||||
n = s.routed_node_ids.len(),
|
||||
"audio routing: cleared target.object on routed streams before quitting"
|
||||
);
|
||||
}
|
||||
}
|
||||
main_loop_for_cmd.quit();
|
||||
}
|
||||
});
|
||||
|
||||
let filter_lower = filter_name.to_ascii_lowercase();
|
||||
let sink_name_owned = sink_name.clone();
|
||||
let registry_weak = registry.downgrade();
|
||||
let state_for_reg = Rc::clone(&state);
|
||||
let event_tx_for_reg = event_tx.clone();
|
||||
let state_for_remove = Rc::clone(&state);
|
||||
let event_tx_for_remove = event_tx.clone();
|
||||
|
||||
let _reg_listener = registry
|
||||
.add_listener_local()
|
||||
.global(move |obj| {
|
||||
let Some(reg) = registry_weak.upgrade() else { return };
|
||||
match obj.type_ {
|
||||
ObjectType::Node => {
|
||||
let Some(props) = obj.props.as_ref() else { return };
|
||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
||||
if let Some(serial) = props
|
||||
.get("object.serial")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
{
|
||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
||||
tracing::info!(
|
||||
serial,
|
||||
"audio routing: pixelpass sink registered"
|
||||
);
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
||||
return;
|
||||
}
|
||||
let Some(app) = props.get("application.name") else { return };
|
||||
if !app.eq_ignore_ascii_case(&filter_lower) {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
node_id = obj.id,
|
||||
%app,
|
||||
"audio routing: matched stream, queued for route"
|
||||
);
|
||||
state_for_reg.borrow_mut().pending.push(obj.id);
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
ObjectType::Metadata => {
|
||||
let Some(props) = obj.props.as_ref() else { return };
|
||||
if props.get("metadata.name") != Some("default") {
|
||||
return;
|
||||
}
|
||||
let metadata: pw::metadata::Metadata = match reg.bind(obj) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("audio routing: bind default metadata failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
state_for_reg.borrow_mut().default_metadata = Some(metadata);
|
||||
tracing::info!("audio routing: default metadata bound");
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.global_remove(move |id| {
|
||||
handle_global_remove(&state_for_remove, &event_tx_for_remove, id);
|
||||
})
|
||||
.register();
|
||||
|
||||
tracing::info!(filter = %filter_name, "audio routing: pw thread running");
|
||||
main_loop.run();
|
||||
tracing::info!("audio routing: pw thread exiting");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RouterState {
|
||||
sink_serial: Option<u32>,
|
||||
default_metadata: Option<pipewire::metadata::Metadata>,
|
||||
routed_node_ids: Vec<u32>,
|
||||
pending: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Drop the vanished node from `routed_node_ids` and `pending`. If it
|
||||
/// was the last routed stream, emit `LastRoutedStreamGone` so the
|
||||
/// tokio side restores the default-sink loopback.
|
||||
fn handle_global_remove(
|
||||
state: &Rc<RefCell<RouterState>>,
|
||||
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
id: u32,
|
||||
) {
|
||||
let mut s = state.borrow_mut();
|
||||
let was_routed = !s.routed_node_ids.is_empty();
|
||||
s.routed_node_ids.retain(|&x| x != id);
|
||||
s.pending.retain(|&x| x != id);
|
||||
if was_routed && s.routed_node_ids.is_empty() {
|
||||
tracing::info!(
|
||||
node_id = id,
|
||||
"audio routing: last routed stream disappeared"
|
||||
);
|
||||
let _ = event_tx.send(Event::LastRoutedStreamGone);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain pending streams to the sink, but only once both prerequisites
|
||||
/// (sink serial known + default metadata bound) are in place. Emits
|
||||
/// `FirstRoutedStream` when routed count crosses 0→N (so it fires
|
||||
/// each time the count comes back up from zero, not just the first
|
||||
/// time — pairs with `LastRoutedStreamGone` to oscillate the loopback).
|
||||
fn try_flush(
|
||||
state: &Rc<RefCell<RouterState>>,
|
||||
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
) {
|
||||
let mut s = state.borrow_mut();
|
||||
let Some(serial) = s.sink_serial else { return };
|
||||
if s.default_metadata.is_none() {
|
||||
return;
|
||||
}
|
||||
if s.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let was_empty = s.routed_node_ids.is_empty();
|
||||
let serial_str = serial.to_string();
|
||||
let pending = std::mem::take(&mut s.pending);
|
||||
if let Some(meta) = &s.default_metadata {
|
||||
for nid in &pending {
|
||||
meta.set_property(*nid, "target.object", Some("Spa:Id"), Some(&serial_str));
|
||||
tracing::info!(
|
||||
node_id = *nid,
|
||||
sink_serial = serial,
|
||||
"audio routing: stream routed to pixelpass sink"
|
||||
);
|
||||
}
|
||||
}
|
||||
s.routed_node_ids.extend(pending);
|
||||
if was_empty && !s.routed_node_ids.is_empty() {
|
||||
let _ = event_tx.send(Event::FirstRoutedStream);
|
||||
}
|
||||
}
|
||||
+16
-31
@@ -1,40 +1,25 @@
|
||||
//! Capture dispatcher. Selects the per-display-server pipeline and returns its
|
||||
//! [`CaptureHandle`]. Each backend owns its own children and tears them down
|
||||
//! via its own Drop / shutdown.
|
||||
//! Capture dispatcher. Picks the per-display-server source backend and returns
|
||||
//! the shared [`pipeline::CaptureHandle`]. The handle itself, its teardown, and
|
||||
//! 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::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 {
|
||||
Wayland(wayland::CaptureHandle),
|
||||
}
|
||||
|
||||
impl 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> {
|
||||
pub async fn spawn(
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
) -> Result<CaptureHandle> {
|
||||
match display {
|
||||
DisplayServer::Wayland => {
|
||||
let h = wayland::start(opts).await?;
|
||||
Ok(CaptureHandle::Wayland(h))
|
||||
}
|
||||
DisplayServer::X11 => {
|
||||
bail!("X11 capture pipeline not yet implemented (Phase 2 follow-up)");
|
||||
}
|
||||
DisplayServer::Wayland => wayland::start(opts, quality).await,
|
||||
DisplayServer::X11 => x11::start(opts, quality).await,
|
||||
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
|
||||
}
|
||||
}
|
||||
|
||||
+335
-54
@@ -1,18 +1,50 @@
|
||||
pub mod audio;
|
||||
mod capture;
|
||||
mod pipeline;
|
||||
mod quality;
|
||||
mod serve;
|
||||
mod wayland;
|
||||
mod x11;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use iroh::Endpoint;
|
||||
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, deps, display::DisplayServer, signal};
|
||||
use crate::common::{
|
||||
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 {
|
||||
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 { id: String },
|
||||
/// Request to kick a specific viewer.
|
||||
KickViewer { id: String },
|
||||
}
|
||||
|
||||
pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
let display = DisplayServer::resolve(opts.display_server);
|
||||
deps::check_host_binaries(display)?;
|
||||
deps::check_host_binaries(display, &opts)?;
|
||||
|
||||
if display == DisplayServer::Unknown {
|
||||
bail!(
|
||||
@@ -21,6 +53,19 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
let cancel = signal::install_ctrl_c();
|
||||
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
@@ -28,101 +73,340 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
// Relay-only ticket: wait for the home relay to connect, then keep only
|
||||
// the endpoint id + relay URL and drop the direct IP candidates. The relay
|
||||
// coordinates hole-punching to a direct path right after connect, so this
|
||||
// doesn't change whether peers can reach each other — it just keeps the
|
||||
// ticket short (~140 vs ~320 chars) and stops it from leaking LAN /
|
||||
// Docker-bridge addresses to whoever receives the ticket. Awaiting online()
|
||||
// first guarantees the relay URL is actually present (addr() right after
|
||||
// bind can return before the relay handshake completes); the 15s cap means
|
||||
// a relay outage degrades to a possibly-incomplete ticket rather than a hang
|
||||
// (n0 DNS discovery still resolves the id in that case).
|
||||
if tokio::time::timeout(Duration::from_secs(15), endpoint.online())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("home relay not connected within 15s; ticket may be incomplete");
|
||||
}
|
||||
let addr = endpoint.addr();
|
||||
let ticket = EndpointTicket::new(addr);
|
||||
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string());
|
||||
print_host_banner(&ticket, display, &opts, clipboard_ok);
|
||||
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 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);
|
||||
|
||||
let result = accept_loop(&endpoint, display, &opts, cancel.clone()).await;
|
||||
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(),
|
||||
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;
|
||||
|
||||
drop(sup_tx);
|
||||
let _ = supervisor.await;
|
||||
|
||||
endpoint.close().await;
|
||||
result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
endpoint: &Endpoint,
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
sup_tx: mpsc::Sender<SupervisorMsg>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("cancellation requested before any peer connected");
|
||||
Ok(())
|
||||
}
|
||||
accepted = endpoint.accept() => {
|
||||
let Some(incoming) = accepted else {
|
||||
bail!("endpoint stopped accepting connections");
|
||||
};
|
||||
let conn = incoming.await?;
|
||||
let remote = conn.remote_id();
|
||||
tracing::info!(%remote, "peer connected");
|
||||
eprintln!("\n[pixelpass] peer connected: {remote}\n");
|
||||
handle_peer(conn, display, opts, cancel).await
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("cancellation requested — closing accept loop");
|
||||
return;
|
||||
}
|
||||
accepted = endpoint.accept() => {
|
||||
let Some(incoming) = accepted else {
|
||||
tracing::info!("endpoint stopped accepting connections");
|
||||
return;
|
||||
};
|
||||
let conn = match incoming.await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("incoming connection failed: {e:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let sup_tx = sup_tx.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::spawn(handle_peer(conn, sup_tx, cancel));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_peer(
|
||||
conn: Connection,
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
sup_tx: mpsc::Sender<SupervisorMsg>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
let (quic_send, quic_recv) = conn.accept_bi().await?;
|
||||
) {
|
||||
let remote = conn.remote_id();
|
||||
let id_str = remote.to_string();
|
||||
let peer_cancel = CancellationToken::new();
|
||||
|
||||
let capture_handle = capture::spawn(display, opts).await?;
|
||||
let port = capture_handle.local_port();
|
||||
let tcp = wayland::connect_to_capture(port, std::time::Duration::from_secs(5)).await?;
|
||||
|
||||
let bridge = crate::common::tunnel::bridge(quic_send, quic_recv, tcp);
|
||||
|
||||
tokio::select! {
|
||||
res = bridge => {
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("bridge ended with error: {e:#}");
|
||||
} else {
|
||||
tracing::info!("bridge closed cleanly");
|
||||
}
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
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;
|
||||
}
|
||||
let port = match reply_rx.await {
|
||||
Ok(Ok(p)) => p,
|
||||
Ok(Err(reason)) => {
|
||||
tracing::warn!(%remote, %reason, "refusing viewer");
|
||||
eprintln!("[pixelpass] refusing viewer {remote}: {reason}");
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(%remote, "supervisor reply dropped; dropping peer");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (quic_send, quic_recv) = match conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(%remote, "accept_bi failed: {e:#}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[pixelpass] viewer connected: {remote}");
|
||||
|
||||
let tcp = match serve::connect_to_capture(port, Duration::from_secs(5)).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let bridge = tunnel::bridge(quic_send, quic_recv, tcp);
|
||||
tokio::select! {
|
||||
res = bridge => match res {
|
||||
Ok(()) => tracing::info!(%remote, "bridge closed cleanly"),
|
||||
Err(e) => tracing::info!(%remote, "bridge ended: {e:#}"),
|
||||
},
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("cancellation requested during stream");
|
||||
tracing::info!(%remote, "cancellation during stream");
|
||||
}
|
||||
_ = peer_cancel.cancelled() => {
|
||||
tracing::info!(%remote, "kicked by host");
|
||||
}
|
||||
}
|
||||
|
||||
capture_handle.shutdown().await;
|
||||
Ok(())
|
||||
eprintln!("[pixelpass] viewer disconnected: {remote}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
|
||||
}
|
||||
|
||||
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
|
||||
/// capture lazily on the first AddViewer; tears it down when the count drops
|
||||
/// back to zero. Enforces the max-viewers cap by refusing AddViewer when
|
||||
/// 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 { id, cancel, reply } => {
|
||||
if count >= max_viewers {
|
||||
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, &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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn print_host_banner(
|
||||
ticket: &EndpointTicket,
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
resolution: &MaxViewersResolution,
|
||||
clipboard_ok: bool,
|
||||
) {
|
||||
eprintln!();
|
||||
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
|
||||
eprintln!("│ display server : {display:?}");
|
||||
eprintln!("│ capture : {}", capture_summary(opts));
|
||||
eprintln!("│ bitrate / fps : {} kbps @ {} fps", opts.bitrate, opts.framerate);
|
||||
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off" } else { "auto (VAAPI if available)" });
|
||||
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!("│");
|
||||
if clipboard_ok {
|
||||
eprintln!("│ Your share code has been copied to your clipboard.");
|
||||
eprintln!("│ Send it to your viewer. (If clipboard didn't work, the");
|
||||
eprintln!("│ Send it to your viewer(s). (If clipboard didn't work, the");
|
||||
eprintln!("│ code is also shown below for manual copy.)");
|
||||
} else {
|
||||
eprintln!("│ Share this ticket with your viewer:");
|
||||
eprintln!("│ Share this ticket with your viewer(s):");
|
||||
}
|
||||
eprintln!("│");
|
||||
eprintln!("│ pixelpass {ticket}");
|
||||
eprintln!("│");
|
||||
eprintln!("│ Capture will not start until the viewer connects.");
|
||||
eprintln!("│ Press Ctrl+C to stop.");
|
||||
eprintln!("│ Capture starts when the first viewer connects, runs while");
|
||||
eprintln!("│ any viewer is connected, and tears down when the last one");
|
||||
eprintln!("│ leaves. Press Ctrl+C to stop the host entirely.");
|
||||
eprintln!("└────────────────────────────────────────────────────────────");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
/// How we arrived at the final viewer cap. Surfaced in the banner so the
|
||||
/// user can tell at a glance whether the number is what they specified,
|
||||
/// what their measured upstream supports, or just the fallback default.
|
||||
struct MaxViewersResolution {
|
||||
value: u32,
|
||||
source: MaxViewersSource,
|
||||
}
|
||||
|
||||
enum MaxViewersSource {
|
||||
/// User passed --max-viewers explicitly.
|
||||
UserFlag,
|
||||
/// Derived from the saved bandwidth measurement.
|
||||
BandwidthMeasurement { safe_mbps: f64 },
|
||||
/// No flag, no measurement — falling back.
|
||||
DefaultFallback,
|
||||
}
|
||||
|
||||
impl MaxViewersSource {
|
||||
fn label(&self) -> String {
|
||||
match self {
|
||||
MaxViewersSource::UserFlag => "user-specified".to_string(),
|
||||
MaxViewersSource::BandwidthMeasurement { safe_mbps } => {
|
||||
format!("auto: {safe_mbps:.1} Mbps measured upstream")
|
||||
}
|
||||
MaxViewersSource::DefaultFallback => {
|
||||
"default — run `pixelpass --reconfigure` for a connection-aware value".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_max_viewers(opts: &HostOpts, effective_bitrate: u32) -> MaxViewersResolution {
|
||||
if let Some(n) = opts.max_viewers {
|
||||
return MaxViewersResolution {
|
||||
value: n,
|
||||
source: MaxViewersSource::UserFlag,
|
||||
};
|
||||
}
|
||||
if let Ok(cfg) = config::load()
|
||||
&& cfg.bandwidth.status == BandwidthStatus::Measured
|
||||
&& let Some(upstream) = cfg.bandwidth.upstream_mbps
|
||||
{
|
||||
let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate);
|
||||
return MaxViewersResolution {
|
||||
value: n,
|
||||
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
|
||||
};
|
||||
}
|
||||
MaxViewersResolution {
|
||||
value: 2,
|
||||
source: MaxViewersSource::DefaultFallback,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> bool {
|
||||
match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text.to_owned())) {
|
||||
Ok(()) => true,
|
||||
@@ -140,8 +424,5 @@ fn capture_summary(opts: &HostOpts) -> String {
|
||||
} else {
|
||||
bits.push("system-audio".to_string());
|
||||
}
|
||||
if opts.mic {
|
||||
bits.push("mic".to_string());
|
||||
}
|
||||
bits.join(" + ")
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Display-server-agnostic serving layer: takes a capture child's stdout
|
||||
//! producing MPEG-TS bytes and fans them out to N concurrent HTTP viewers
|
||||
//! on a localhost port. One reader task pumps stdout chunks into a
|
||||
//! tokio::sync::broadcast channel; the accept loop spawns one drain task
|
||||
//! per accepted TCP connection. Slow consumers see Lagged and skip ahead;
|
||||
//! MPEG-TS resyncs at the next keyframe.
|
||||
//!
|
||||
//! Backends (host/wayland.rs, future host/x11.rs) build their own gst
|
||||
//! pipeline and hand the resulting ChildStdout to [`Serve::bind`].
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::process::ChildStdout;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
/// Broadcast-channel capacity in chunks. Each chunk is up to 64 KiB from
|
||||
/// the capture child's stdout, so 16 chunks ≈ 1 MiB ≈ ~2 s of buffered
|
||||
/// jitter at typical bitrates. A viewer that falls behind by more than
|
||||
/// this gets Lagged and skips ahead — MPEG-TS recovers at the next
|
||||
/// keyframe.
|
||||
const FANOUT_CAPACITY: usize = 16;
|
||||
|
||||
/// Size of each chunk read from the capture child's stdout.
|
||||
const READ_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// Owns the localhost HTTP listener and the two long-running tasks that
|
||||
/// pump bytes from a capture child to all connected viewers.
|
||||
pub struct Serve {
|
||||
port: u16,
|
||||
reader: Option<JoinHandle<()>>,
|
||||
server: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Serve {
|
||||
/// Bind a localhost listener on a random port, set up the broadcast
|
||||
/// fanout, and spawn the reader + accept-loop tasks. The provided
|
||||
/// `stdout` is assumed to produce MPEG-TS bytes.
|
||||
pub async fn bind(stdout: ChildStdout) -> Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.context("could not bind local capture HTTP listener")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let (tx, _) = broadcast::channel::<Arc<Vec<u8>>>(FANOUT_CAPACITY);
|
||||
let reader = tokio::spawn(pump_to_broadcast(stdout, tx.clone()));
|
||||
let server = tokio::spawn(run_accept_loop(listener, tx));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
reader: Some(reader),
|
||||
server: Some(server),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn local_port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Abort the reader and accept-loop tasks. Backends typically call this
|
||||
/// after killing their capture child so the reader sees stdout EOF and
|
||||
/// exits on its own; the abort is a backstop.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(task) = self.reader.take() {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(task) = self.server.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Serve {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self.reader.as_ref() {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(task) = self.server.as_ref() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the local capture HTTP listener, retrying until it's up or
|
||||
/// we time out. Returns the connected socket — the bridge layer pipes
|
||||
/// QUIC↔this socket once it's open.
|
||||
pub async fn connect_to_capture(port: u16, max_wait: Duration) -> Result<TcpStream> {
|
||||
let deadline = Instant::now() + max_wait;
|
||||
loop {
|
||||
match TcpStream::connect(("127.0.0.1", port)).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(_) if Instant::now() < deadline => {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(e) => bail!("capture HTTP listener never came up on 127.0.0.1:{port}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the capture child's stdout in chunks and broadcast each to all
|
||||
/// current subscribers. `broadcast::send` returns Err when there are no
|
||||
/// receivers; we ignore it so the capture child isn't backpressured
|
||||
/// waiting for a viewer.
|
||||
async fn pump_to_broadcast(mut stdout: ChildStdout, tx: broadcast::Sender<Arc<Vec<u8>>>) {
|
||||
let mut buf = vec![0u8; READ_CHUNK];
|
||||
loop {
|
||||
match stdout.read(&mut buf).await {
|
||||
Ok(0) => {
|
||||
tracing::info!("capture stdout EOF — fanout reader exiting");
|
||||
return;
|
||||
}
|
||||
Ok(n) => {
|
||||
let chunk = Arc::new(buf[..n].to_vec());
|
||||
let _ = tx.send(chunk);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("capture stdout read error: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_accept_loop(listener: TcpListener, tx: broadcast::Sender<Arc<Vec<u8>>>) {
|
||||
loop {
|
||||
let sock = match listener.accept().await {
|
||||
Ok((s, _)) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!("capture HTTP accept failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let rx = tx.subscribe();
|
||||
tokio::spawn(serve_one_viewer(sock, rx));
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one_viewer(mut sock: TcpStream, mut rx: broadcast::Receiver<Arc<Vec<u8>>>) {
|
||||
if !drain_http_request(&mut sock).await {
|
||||
return;
|
||||
}
|
||||
|
||||
const RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: video/mp2t\r\n\
|
||||
Cache-Control: no-cache, no-store\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n";
|
||||
if sock.write_all(RESPONSE).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(chunk) => {
|
||||
if sock.write_all(&chunk).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
"viewer fanout lagged — MPEG-TS will resync at next keyframe"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_http_request(sock: &mut TcpStream) -> bool {
|
||||
let mut buf = [0u8; 1024];
|
||||
let mut total = Vec::with_capacity(512);
|
||||
loop {
|
||||
match sock.read(&mut buf).await {
|
||||
Ok(0) => return false,
|
||||
Ok(n) => total.extend_from_slice(&buf[..n]),
|
||||
Err(_) => return false,
|
||||
}
|
||||
if total.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
return true;
|
||||
}
|
||||
if total.len() > 16 * 1024 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-237
@@ -1,9 +1,9 @@
|
||||
//! Wayland capture: ashpd ScreenCast portal → PipeWire fd → gst-launch
|
||||
//! pipewiresrc → MPEG-TS on gst stdout → in-process HTTP server bound on a
|
||||
//! random localhost port. The host bridge TCP-connects to that server and
|
||||
//! pumps bytes to QUIC.
|
||||
//! Wayland capture: ashpd ScreenCast portal → PipeWire fd → `pipewiresrc`.
|
||||
//! This module owns only the portal handshake and the source-element args;
|
||||
//! the shared encode/mux tail, gst spawn, and serving live in
|
||||
//! [`super::pipeline`].
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result};
|
||||
use ashpd::{
|
||||
WindowIdentifier,
|
||||
desktop::{
|
||||
@@ -12,61 +12,14 @@ use ashpd::{
|
||||
},
|
||||
};
|
||||
use nix::fcntl::{FcntlArg, FdFlag, fcntl};
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::{Pid, close};
|
||||
use nix::unistd::close;
|
||||
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::process::{Child, ChildStdout, Command};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, sleep, timeout};
|
||||
|
||||
use super::pipeline::{self, CaptureHandle};
|
||||
use super::quality::EffectiveQuality;
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
pub struct CaptureHandle {
|
||||
port: u16,
|
||||
gst: Option<Child>,
|
||||
server: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
pub fn local_port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL, then
|
||||
/// abort the HTTP server task. Call this before dropping; Drop only fires
|
||||
/// the kill 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(task) = self.server.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CaptureHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.gst.as_mut() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
if let Some(task) = self.server.as_ref() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -105,154 +58,23 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
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
|
||||
// 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)?;
|
||||
let raw_fd: RawFd = pw_fd.into_raw_fd();
|
||||
|
||||
// 2. Bind the in-process HTTP listener on a random localhost port.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.context("could not bind local capture HTTP listener")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let source_args = vec![
|
||||
"pipewiresrc".to_string(),
|
||||
format!("fd={raw_fd}"),
|
||||
format!("path={node_id}"),
|
||||
"do-timestamp=true".to_string(),
|
||||
];
|
||||
|
||||
// 3. Spawn gst-launch with the full pipeline: video AND audio captured,
|
||||
// encoded, and muxed into MPEG-TS inside gst. Output goes to stdout,
|
||||
// which we pipe straight to our HTTP server task — no demux/remux,
|
||||
// no codec assumptions.
|
||||
let key_interval = (opts.framerate * 2).to_string();
|
||||
let bitrate = opts.bitrate.to_string();
|
||||
let audio_monitor = default_audio_monitor().await?;
|
||||
let audio_device = format!("device={audio_monitor}");
|
||||
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")?;
|
||||
|
||||
// 4. Spawn the HTTP server task. It owns the listener + gst stdout: it
|
||||
// accepts one client (the host's bridge socket via connect_to_capture),
|
||||
// drains the HTTP request, writes a fixed MPEG-TS response, then
|
||||
// copies gst stdout to the socket forever.
|
||||
let server = tokio::spawn(serve_capture(listener, gst_stdout));
|
||||
|
||||
Ok(CaptureHandle {
|
||||
port,
|
||||
gst: Some(gst),
|
||||
server: Some(server),
|
||||
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);
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve_capture(listener: TcpListener, mut gst_stdout: ChildStdout) {
|
||||
let mut sock = match listener.accept().await {
|
||||
Ok((s, _)) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!("capture HTTP accept failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !drain_http_request(&mut sock).await {
|
||||
return;
|
||||
}
|
||||
|
||||
const RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: video/mp2t\r\n\
|
||||
Cache-Control: no-cache, no-store\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n";
|
||||
if sock.write_all(RESPONSE).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tokio::io::copy(&mut gst_stdout, &mut sock).await;
|
||||
}
|
||||
|
||||
async fn drain_http_request(sock: &mut TcpStream) -> bool {
|
||||
let mut buf = [0u8; 1024];
|
||||
let mut total = Vec::with_capacity(512);
|
||||
loop {
|
||||
match sock.read(&mut buf).await {
|
||||
Ok(0) => return false,
|
||||
Ok(n) => total.extend_from_slice(&buf[..n]),
|
||||
Err(_) => return false,
|
||||
}
|
||||
if total.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
return true;
|
||||
}
|
||||
if total.len() > 16 * 1024 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
.await
|
||||
}
|
||||
|
||||
fn clear_cloexec(fd: &impl AsFd) -> Result<()> {
|
||||
@@ -262,41 +84,3 @@ fn clear_cloexec(fd: &impl AsFd) -> Result<()> {
|
||||
fcntl(fd.as_fd(), FcntlArg::F_SETFD(flags)).context("F_SETFD on pipewire fd")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect to the in-process capture HTTP listener, retrying until it's up or
|
||||
/// we time out. Returns the connected socket — the listener accepts exactly
|
||||
/// one connection (the bridge socket), so this stream IS the bridge socket.
|
||||
pub async fn connect_to_capture(port: u16, max_wait: Duration) -> Result<TcpStream> {
|
||||
let deadline = Instant::now() + max_wait;
|
||||
loop {
|
||||
match TcpStream::connect(("127.0.0.1", port)).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(_) if Instant::now() < deadline => {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(e) => bail!("capture HTTP listener never came up on 127.0.0.1:{port}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
-6
@@ -3,7 +3,8 @@ 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};
|
||||
|
||||
pub async fn run(cli: Cli) -> Result<()> {
|
||||
@@ -20,7 +21,17 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||
.interact()?;
|
||||
|
||||
match choice {
|
||||
0 => host::run(cli.into_host_opts(true)).await,
|
||||
0 => {
|
||||
preflight_if_needed(&theme).await;
|
||||
let mut cli = cli;
|
||||
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
|
||||
}
|
||||
_ => {
|
||||
let ticket = prompt_ticket(&theme)?;
|
||||
viewer::run(ticket, cli.into_viewer_opts(true)).await
|
||||
@@ -28,6 +39,218 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Picker for the per-app audio capture choice. Lists apps currently
|
||||
/// producing audio (deduped by `application.name`); user picks one or
|
||||
/// the "all system audio" default. Bypassed when `--app NAME` was given
|
||||
/// on the CLI.
|
||||
fn pick_app(theme: &ColorfulTheme) -> Result<Option<String>> {
|
||||
let apps = match host::audio::list_playing_apps() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("could not enumerate playing apps: {e:#}");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!();
|
||||
eprintln!("Audio capture");
|
||||
eprintln!("─────────────");
|
||||
if apps.is_empty() {
|
||||
eprintln!("No other apps are currently producing audio.");
|
||||
eprintln!("Start your game / music / call first if you want to pick it specifically.");
|
||||
}
|
||||
eprintln!();
|
||||
|
||||
let mut items = vec!["Capture all system audio (default)".to_string()];
|
||||
for app in &apps {
|
||||
items.push(if app.stream_count == 1 {
|
||||
app.name.clone()
|
||||
} else {
|
||||
format!("{} ({} streams)", app.name, app.stream_count)
|
||||
});
|
||||
}
|
||||
|
||||
let choice = Select::with_theme(theme)
|
||||
.with_prompt("What audio should the viewer hear?")
|
||||
.items(&items)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
if choice == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(apps[choice - 1].name.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub async fn run_reconfigure() -> Result<()> {
|
||||
eprintln!();
|
||||
eprintln!("Re-running bandwidth pre-flight test…");
|
||||
let mut cfg = config::load().unwrap_or_default();
|
||||
run_bandwidth_test(&mut cfg).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// First-run pre-flight gate. Called once, when the user picks "Host" in
|
||||
/// the interactive menu. Behavior by saved status:
|
||||
/// - Unmeasured (first ever launch): explain + offer Run / Skip
|
||||
/// - Failed (previous attempt errored): offer Retry / give-up-and-skip
|
||||
/// - Measured or Skipped: silent — never re-prompts
|
||||
async fn preflight_if_needed(theme: &ColorfulTheme) {
|
||||
let mut cfg = config::load().unwrap_or_default();
|
||||
match cfg.bandwidth.status {
|
||||
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => return,
|
||||
config::BandwidthStatus::Unmeasured => {
|
||||
eprintln!();
|
||||
eprintln!("First-time setup");
|
||||
eprintln!("────────────────");
|
||||
eprintln!("PixelPass can measure your upload speed to recommend a safe");
|
||||
eprintln!("default for how many viewers your connection can handle.");
|
||||
eprintln!("The test takes about 5 seconds and uploads ~5 MB to");
|
||||
eprintln!("Cloudflare's open speed-test endpoint.");
|
||||
eprintln!();
|
||||
eprintln!("If you skip, a conservative default (2 viewers) is used.");
|
||||
eprintln!("You can run the test later with `pixelpass --reconfigure`.");
|
||||
eprintln!();
|
||||
|
||||
let Ok(choice) = Select::with_theme(theme)
|
||||
.with_prompt("What would you like to do?")
|
||||
.items(&[
|
||||
"Run the bandwidth test (recommended)",
|
||||
"Skip — use the conservative default",
|
||||
])
|
||||
.default(0)
|
||||
.interact()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if choice == 1 {
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Skipped,
|
||||
upstream_mbps: None,
|
||||
measured_at: None,
|
||||
};
|
||||
let _ = config::save(&cfg);
|
||||
eprintln!("Pre-flight skipped.");
|
||||
return;
|
||||
}
|
||||
run_bandwidth_test(&mut cfg).await;
|
||||
}
|
||||
config::BandwidthStatus::Failed => {
|
||||
eprintln!();
|
||||
let Ok(choice) = Select::with_theme(theme)
|
||||
.with_prompt("Last bandwidth test failed. Try again?")
|
||||
.items(&[
|
||||
"Yes — retry now",
|
||||
"No — use the conservative default",
|
||||
])
|
||||
.default(0)
|
||||
.interact()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if choice == 1 {
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Skipped,
|
||||
upstream_mbps: None,
|
||||
measured_at: None,
|
||||
};
|
||||
let _ = config::save(&cfg);
|
||||
eprintln!("OK — using the conservative default.");
|
||||
return;
|
||||
}
|
||||
run_bandwidth_test(&mut cfg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_bandwidth_test(cfg: &mut config::Config) {
|
||||
eprintln!();
|
||||
eprintln!("Measuring upstream…");
|
||||
|
||||
let result = tokio::task::spawn_blocking(bandwidth::measure_upstream_blocking).await;
|
||||
let measurement = match result {
|
||||
Ok(Ok(m)) => m,
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("Test failed: {e:#}");
|
||||
eprintln!("Marking as failed — you'll be asked again on next launch.");
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Failed,
|
||||
upstream_mbps: None,
|
||||
measured_at: None,
|
||||
};
|
||||
let _ = config::save(cfg);
|
||||
return;
|
||||
}
|
||||
Err(join_err) => {
|
||||
eprintln!("Test task panicked: {join_err}");
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Failed,
|
||||
upstream_mbps: None,
|
||||
measured_at: None,
|
||||
};
|
||||
let _ = config::save(cfg);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"Measured {:.2} Mbps up (safe estimate {:.2} Mbps, took {:.1}s).",
|
||||
measurement.raw_mbps,
|
||||
measurement.safe_mbps,
|
||||
measurement.elapsed.as_secs_f64()
|
||||
);
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Measured,
|
||||
upstream_mbps: Some(measurement.safe_mbps),
|
||||
measured_at: Some(chrono::Utc::now()),
|
||||
};
|
||||
if let Err(e) = config::save(cfg) {
|
||||
eprintln!("Warning: failed to save result: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
fn print_welcome() {
|
||||
eprintln!();
|
||||
eprintln!("Welcome to PixelPass.");
|
||||
@@ -68,14 +291,52 @@ impl Player {
|
||||
url,
|
||||
],
|
||||
),
|
||||
Player::Vlc => crate::common::process::spawn_detached(
|
||||
"vlc",
|
||||
&["--network-caching=200", "--live-caching=200", url],
|
||||
),
|
||||
Player::Vlc => {
|
||||
warn_if_vlc_plugins_missing();
|
||||
crate::common::process::spawn_detached(
|
||||
"vlc",
|
||||
&["--network-caching=200", "--live-caching=200", url],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On Arch-family distros, the base `vlc` package omits two plugins
|
||||
// pixelpass needs: the MPEG-TS demuxer (`vlc-plugin-dvb`) and the
|
||||
// libavcodec-based H.264 decoder (`vlc-plugin-ffmpeg`). Missing either
|
||||
// produces a confusing error chain — warn at launch.
|
||||
fn warn_if_vlc_plugins_missing() {
|
||||
const REQUIRED: &[(&str, &str)] = &[
|
||||
(
|
||||
"/usr/lib/vlc/plugins/demux/libts_plugin.so",
|
||||
"vlc-plugin-dvb",
|
||||
),
|
||||
(
|
||||
"/usr/lib/vlc/plugins/codec/libavcodec_plugin.so",
|
||||
"vlc-plugin-ffmpeg",
|
||||
),
|
||||
];
|
||||
let missing: Vec<&(&str, &str)> = REQUIRED
|
||||
.iter()
|
||||
.filter(|(p, _)| !std::path::Path::new(p).exists())
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!("Warning: VLC is missing plugins pixelpass needs:");
|
||||
for (path, pkg) in &missing {
|
||||
eprintln!(" - {path} (install `{pkg}`)");
|
||||
}
|
||||
eprintln!("On Arch / CachyOS / EndeavourOS: `sudo pacman -S {}`.", {
|
||||
let names: Vec<&str> = missing.iter().map(|(_, p)| *p).collect();
|
||||
names.join(" ")
|
||||
});
|
||||
eprintln!("mpv is unaffected.");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
pub fn prompt_player() -> Result<Player> {
|
||||
let theme = ColorfulTheme::default();
|
||||
let choice = Select::with_theme(&theme)
|
||||
|
||||
+48
-1
@@ -1,5 +1,7 @@
|
||||
mod cli;
|
||||
mod common;
|
||||
#[cfg(feature = "gui")]
|
||||
mod gui;
|
||||
mod host;
|
||||
mod interactive;
|
||||
mod repair;
|
||||
@@ -16,10 +18,46 @@ 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();
|
||||
|
||||
if cli.repair {
|
||||
return repair::run().await;
|
||||
}
|
||||
|
||||
if cli.reconfigure {
|
||||
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| {
|
||||
@@ -37,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();
|
||||
}
|
||||
|
||||
+192
-7
@@ -1,12 +1,197 @@
|
||||
//! `--repair`: clean up any null sinks / loopbacks that a crashed pixelpass
|
||||
//! host left behind. Phase 2 will scan PipeWire for nodes tagged with the
|
||||
//! `pixelpass.session = <uuid>` property and destroy them.
|
||||
//! `--repair`: clean up null-sinks and loopbacks left behind by a crashed
|
||||
//! pixelpass host. Identifies orphans by the `pixelpass_capture_<pid>`
|
||||
//! name pattern + dead-PID check, then unloads paired loopbacks first
|
||||
//! (mirrors `Routing::shutdown`'s order so PipeWire doesn't leave zombie
|
||||
//! links). Live PIDs — including this process and any other running
|
||||
//! pixelpass — are left alone.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const SINK_NAME_PREFIX: &str = "pixelpass_capture_";
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
eprintln!("[pixelpass] --repair: PipeWire scan not yet implemented (Phase 2).");
|
||||
eprintln!(" Run `pactl list short sinks | grep pixelpass` to spot orphans,");
|
||||
eprintln!(" and `pactl unload-module <id>` to remove them manually.");
|
||||
let modules = list_modules().context("failed to list pactl modules")?;
|
||||
|
||||
let mut dead_sinks: Vec<OrphanSink> = Vec::new();
|
||||
let mut dead_pids: HashSet<u32> = HashSet::new();
|
||||
let mut live_skipped: u32 = 0;
|
||||
|
||||
for m in &modules {
|
||||
if m.name != "module-null-sink" {
|
||||
continue;
|
||||
}
|
||||
let Some(sink_name) = extract_kv(&m.args, "sink_name") else {
|
||||
continue;
|
||||
};
|
||||
let Some(pid_str) = sink_name.strip_prefix(SINK_NAME_PREFIX) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pid) = pid_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if is_pid_alive(pid) {
|
||||
live_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
dead_pids.insert(pid);
|
||||
dead_sinks.push(OrphanSink {
|
||||
id: m.id,
|
||||
sink_name: sink_name.to_string(),
|
||||
pid,
|
||||
});
|
||||
}
|
||||
|
||||
let mut dead_loopbacks: Vec<u32> = Vec::new();
|
||||
for m in &modules {
|
||||
if m.name != "module-loopback" {
|
||||
continue;
|
||||
}
|
||||
let Some(sink) = extract_kv(&m.args, "sink") else {
|
||||
continue;
|
||||
};
|
||||
let Some(pid_str) = sink.strip_prefix(SINK_NAME_PREFIX) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pid) = pid_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
if dead_pids.contains(&pid) {
|
||||
dead_loopbacks.push(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
if dead_sinks.is_empty() && dead_loopbacks.is_empty() {
|
||||
if live_skipped > 0 {
|
||||
println!(
|
||||
"[pixelpass] --repair: nothing to clean up ({live_skipped} live pixelpass host(s) left alone)."
|
||||
);
|
||||
} else {
|
||||
println!("[pixelpass] --repair: nothing to clean up.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut unloaded = 0u32;
|
||||
let mut failed = 0u32;
|
||||
|
||||
for id in &dead_loopbacks {
|
||||
match unload_module(*id) {
|
||||
Ok(()) => {
|
||||
println!("[pixelpass] --repair: unloaded loopback module #{id}");
|
||||
unloaded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[pixelpass] --repair: failed to unload loopback #{id}: {e:#}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for orphan in &dead_sinks {
|
||||
match unload_module(orphan.id) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"[pixelpass] --repair: unloaded {} (orphaned from pid {})",
|
||||
orphan.sink_name, orphan.pid
|
||||
);
|
||||
unloaded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: failed to unload {} (#{}): {e:#}",
|
||||
orphan.sink_name, orphan.id
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if live_skipped > 0 {
|
||||
println!(
|
||||
"[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone."
|
||||
);
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
bail!("--repair: {failed} module(s) failed to unload (see errors above)");
|
||||
}
|
||||
println!("[pixelpass] --repair: cleaned up {unloaded} module(s).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Module {
|
||||
id: u32,
|
||||
name: String,
|
||||
args: String,
|
||||
}
|
||||
|
||||
struct OrphanSink {
|
||||
id: u32,
|
||||
sink_name: String,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
fn list_modules() -> Result<Vec<Module>> {
|
||||
let output = Command::new("pactl")
|
||||
.args(["list", "short", "modules"])
|
||||
.output()
|
||||
.context("failed to run `pactl list short modules`")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl list short modules failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let text = String::from_utf8(output.stdout).context("pactl returned non-UTF-8")?;
|
||||
let mut modules = Vec::new();
|
||||
// `pactl list short modules` is tab-separated, but some modules have
|
||||
// multi-line `{ ... }` argument blocks that wrap onto continuation
|
||||
// lines starting with whitespace. The wrap lines never parse as a
|
||||
// u32 ID, so the simple per-line + parse-id filter is robust.
|
||||
for line in text.lines() {
|
||||
let mut parts = line.splitn(4, '\t');
|
||||
let Some(id_str) = parts.next() else { continue };
|
||||
let Ok(id) = id_str.parse::<u32>() else { continue };
|
||||
let Some(name) = parts.next() else { continue };
|
||||
let args = parts.next().unwrap_or("").to_string();
|
||||
modules.push(Module {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
args,
|
||||
});
|
||||
}
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
|
||||
for token in args.split_whitespace() {
|
||||
if let Some(rest) = token.strip_prefix(key)
|
||||
&& let Some(value) = rest.strip_prefix('=')
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_pid_alive(pid: u32) -> bool {
|
||||
Path::new(&format!("/proc/{pid}")).exists()
|
||||
}
|
||||
|
||||
fn unload_module(id: u32) -> Result<()> {
|
||||
let output = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(id.to_string())
|
||||
.output()
|
||||
.context("failed to run pactl unload-module")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl unload-module #{id}: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+36
-3
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user