Found in a wider bug audit of the streaming/process-management code. - Viewer ctrl-c/SIGINT was ignored mid-stream: viewer::run raced the cancel token only against listener.accept(), not the bridge itself, so once the local player connected nothing checked it. CLI needed a second ctrl-c to quit and a GUI "Disconnect" only took effect via the child's 2s SIGKILL backstop (and the host saw the viewer ~2s longer). Now races the bridge against cancel, mirroring the host's handle_peer. (viewer/mod.rs) - Wayland portal pipewire fd leaked on a capture-setup error: wayland::start into_raw_fd'd the fd and relied on pipeline::spawn's after_spawn hook to close it, but setup_audio/gst-spawn can ?-return before the hook runs, leaking the fd per failed attempt. Now the OwnedFd is moved into the hook, so it's closed whether the hook runs or (on early error) the unused closure is dropped. (host/wayland.rs) - Detached players (mpv/vlc) zombied under the long-lived GUI: spawn_detached dropped the std Child, which has no orphan reaping, so each closed player left a <defunct> entry until the GUI exited. Now a detached thread wait()s it; the setsid'd player still survives a parent exit (init reaps it then). A double-fork was avoided deliberately — fork(2) + non-trivial work in this multithreaded process is unsound. (common/process.rs) 47 gui / 8 headless tests pass, clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 <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. Connected viewers are listed with a Kick button each, and a desktop notification fires as they join or leave. View: paste a code, pick mpv or VLC, click Connect and the player launches.
A system-tray icon shows current status. Settings → "Keep running in the tray when I close the window" (off by default) makes the close button hide the window — truly, by dropping it — while any active stream keeps running in the child; reopen it from the tray. (Plain close still quits when the option is off, or when no system tray is present.)
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.
Relay
By default pixelpass uses iroh's bundled relay servers to coordinate the P2P connection (peers still hole-punch a direct UDP path when they can; the relay is the fallback and the rendezvous point). You can point it at a different relay — a self-hosted one, or n0's staging/production servers — with either:
pixelpass --relay https://relay.example/ # host or viewer
PIXELPASS_RELAY=https://relay.example/ pixelpass … # env-var form
The flag applies to both host and viewer and takes precedence over the
environment variable. The env-var form is handy for the --gui front-end,
since the GUI's child host/viewer processes inherit it; the --gui --relay
flag form is forwarded to them too. Both ends must use the same relay to
find each other.
Themes
The --gui front-end ships three colour themes — Default Dark,
Catppuccin Mocha, and Catppuccin Latte — and you can add your own.
Pick one under Settings → Appearance; the choice is remembered.
A theme is a small TOML file of named colours:
name = "My Theme"
dark = true # base egui defaults to start from (dark or light)
window_bg = "#1b1b1f" # window background
panel_bg = "#242429" # panels / frames
input_bg = "#141417" # text fields, the ticket box
text = "#e6e6ea" # primary text
weak_text = "#a0a0a8" # hints, secondary text
accent = "#5aa0f2" # selection, links, the active control
button_bg = "#33333a" # buttons at rest
button_hovered = "#44444d"
streaming = "#6fdc8c" # "● Streaming"
waiting = "#f2c14e" # "● Waiting for viewers…"
success = "#6fdc8c" # "✓ Copied", valid-code confirmation
warning = "#f0a85a" # non-fatal warnings
error = "#f2756f" # errors
Colours are #rrggbb hex strings. Any field you leave out falls back to
Default Dark, so partial files are fine.
Two ways to make one:
- In the app: Settings → Appearance → Edit / create a theme gives you a
colour picker per field with a live preview, and Save writes a
.toml. - By hand: drop a
.tomlinto~/.config/pixelpass/themes/(the XDG config dir). It appears in the picker next time you open Settings.
Sharing a theme is just sending someone the file. A user theme whose name
matches a built-in overrides that built-in.
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.