b1d73caedf
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
391 lines
16 KiB
Markdown
391 lines
16 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
|
|
- Quality presets (`--quality source|high|medium|low|auto`) that trade
|
|
resolution + bitrate for upload bandwidth, plus an `Auto` mode that
|
|
derives quality from the bandwidth pre-flight
|
|
|
|
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
|
|
```
|
|
|
|
### Graphical (optional)
|
|
|
|
A small window front-end is available in builds compiled with the `gui`
|
|
feature (see [Build](#build)):
|
|
|
|
```sh
|
|
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) 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.
|
|
|
|
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:
|
|
|
|
```sh
|
|
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 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.
|
|
|
|
## 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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```toml
|
|
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 `.toml` into `~/.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 `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. 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-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.
|