Track viewers by endpoint id instead of a bare count. The JSON event stream gains viewer_joined / viewer_left (each carrying the id), replacing viewer_count; active/max still ride along so the count display is unchanged. The host screen now renders one row per connected viewer with a Kick button. Clicking it sends `kick <id>` to the headless child over a new stdin command channel, which the host turns into a per-viewer CancellationToken cancel; the existing teardown path then emits the leave, so a kick and a self-disconnect look identical downstream. The stdin channel only runs under --output json (the GUI shell-out) and on a detached OS thread, so a read parked on stdin can't hold up the host's Ctrl+C shutdown. 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)
- 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>) --repaircleanup 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.tomland used to auto-size the default viewer cap - Quality presets (
--quality source|high|medium|low|auto) that trade resolution + bitrate for upload bandwidth, plus anAutomode that derives quality from the bandwidth pre-flight
Not yet built (deferred, not blocking):
- Per-monitor selection on a multi-monitor X11 host —
ximagesrcgrabs the whole root canvas; single-monitor cropping needs xrandr region coords use-damage=trueCPU optimization for the X11 capture path
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
Graphical (optional)
A small window front-end is available in builds compiled with the gui
feature (see Build):
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) 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.
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:
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 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.
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
ximagesrcand starts silently when the first viewer connects — the ticket is the access control, there's no portal gate.--windowruns anxwininfopicker (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-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.