45e5d7ef37
pixelpass is a screen-share tool meant to be paired with a dedicated voice app (Mumble, TeamSpeak, Discord, …) for two-way talk — it never mixes a mic. The --mic flag was declared, shown in the host banner, and documented as working, but was never wired into the gst pipeline (a no-op). Removed the flag from Cli + HostOpts + into_host_opts, dropped it from the banner capture summary, and replaced the README's "--mic mixes the mic" claim with an explicit out-of-scope note. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
266 lines
11 KiB
Markdown
266 lines
11 KiB
Markdown
# 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
|
|
|
|
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
|
|
```
|
|
|
|
## 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.
|
|
|
|
## 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.
|
|
|
|
## 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.
|