Files
mollusk 45e5d7ef37 feat(cli): remove --mic (microphone is 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 — 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>
2026-05-23 21:15:45 -04:00

11 KiB

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>)
  • --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

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 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:

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 ─┐   (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:

[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.