Updates the status section to move multi-viewer out of "not yet working", adds a Configuration section pointing at the new TOML config at ~/.config/pixelpass/config.toml, and a Multi-viewer section covering the lazy-sticky lifecycle, the --max-viewers cap, the bandwidth-bitrate tradeoff, and how to fit more viewers by dropping --bitrate. Known-limitations section gains "late joiners see ~2 s of garbage" (expected behavior) and drops the now-stale "single viewer per host" line. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pixelpass
P2P screen sharing CLI for Linux. Single binary, hole-punched over iroh — 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)
- VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable GPUs should work)
- Audio capture of the default sink's monitor
- 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.tomland used to auto-size the default viewer cap
Not yet working:
- X11 capture (stubbed, returns an error)
- Per-app audio routing (
--app <name>is a flag stub) --repair(PipeWire orphan cleanup) is a stub
Quick start
Interactive (recommended)
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
# 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 session for now; X11 stubbed)
- A VAAPI-capable GPU and the right driver:
- AMD:
libva-mesa-driver - Intel:
intel-media-driver(modern iGPUs) orintel-vaapi-driver(older) - NVIDIA:
libva-nvidia-driver(untested)
- AMD:
vainfofromlibva-utilsshould 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) orvlc - If you use VLC, two split plugin packages are also needed on Arch-family
distros — the base
vlcpackage 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 withCodec 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:
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
cargo build --release
./target/release/pixelpass --help
rustc 1.95+ / edition 2024.
How it works
Host Viewer
──── ──────
Wayland portal (ashpd) ──> PipeWire fd
│
▼
gst-launch: pipewiresrc -> videorate -> vah264enc ->
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 hardware-encoded 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 1is 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:
[bandwidth]
status = "measured" # measured | skipped | failed | unmeasured
upstream_mbps = 8.78 # safe estimate (raw * 0.8)
measured_at = "2026-05-21T20:41:16Z"
pixelpass --reconfigurere-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.
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-dvbandvlc-plugin-ffmpegon Arch-family distros — the basevlcpackage 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
.soon disk. mpv's--hwdec=autosilently falls back to software decode otherwise, which then chokes on a low-power viewer.
License
MIT OR Apache-2.0, your pick.