Compare commits

12 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 b6240c17c5 viewer: drop forced --hwdec=auto (froze video on frame 1)
The screen-share viewer ran mpv with --profile=low-latency (hwdec off by
default) and then forced --hwdec=auto back on. On some drivers the HW H.264
decoder stalls mid-stream: a viewer receiving a software-x264 share froze on
the first frame while audio kept playing (one MPEG-TS byte stream, so bytes
were still flowing — the video decoder gave up, the audio decoder didn't).
Screen-share H.264 at these bitrates decodes trivially in software, so leave
hwdec at the low-latency default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:15:11 -04:00
molluskandClaude Opus 4.8 c1b21b32c7 Add pixelpass --doctor environment diagnostic
Screen-share failures are usually environment gaps, not pixelpass bugs —
most often a GPU/driver with no working VA-API H.264 encoder, so the
default vah264enc pipeline produces no video and the viewer "can't
connect." doctor probes the whole chain and prints one actionable report
so a remote tester can read it over a call instead of us guessing from
logs, and it validates any X11/Wayland test environment we stand up.

Checks (each a ✓/!/✗ line with a distro-aware install hint):
- display server (Wayland/X11 + session env), and the X server vendor/
  version so an xlibre server is distinguishable from stock Xorg
- capture: gst tools + the backend's source element (pipewiresrc/ximagesrc)
- encode: hardware H.264 (vah264enc + DRM render node + a VA-API H.264
  *encode* entrypoint parsed from vainfo) and the software x264 fallback
- mux/audio tail + pactl
- viewer player (mpv/vlc)
- network: binds a real endpoint and checks relay reachability

Unlike deps::check_host_binaries (bails on first miss), doctor runs every
check and reports them together. Closes with a specific hosting verdict and
exits non-zero on any hard failure so scripts/CI can gate. Pure seams
(vainfo entrypoint parse, summary tally, hosting verdict) are unit-tested;
deps.rs gained pub(crate) which/gst_element_exists/install-hint/distro
helpers so doctor reuses the same package-name knowledge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:19:06 -04:00
molluskandClaude Opus 4.8 b0ff20fe3f host/x11: default to XDamage capture; drop --untimed from viewers
X11 full-desktop capture used `ximagesrc use-damage=false`, which copies
the whole root window every frame. On servers without working MIT-SHM
(and CPU-bound everywhere else) this collapses to ~1 fps — a field test
over an xlibre host played back at roughly one frame per minute. Default
to `use-damage=true` (XDamage re-grabs only changed regions); keep
`PIXELPASS_X11_NO_DAMAGE=1` as an escape hatch for driver artifacts.

Also drop `--untimed` from both mpv invocations (viewer banner + the
interactive launcher). `--untimed` displays each frame as it decodes and
ignores audio timestamps, which drifts a shared *video* progressively
out of sync with its audio. Pacing to the audio clock keeps A/V synced
at a negligible latency cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:29:44 -04:00
molluskandClaude Opus 4.8 b5c03e7705 fix(host): let the sharer hear the app they're sharing (local monitor)
In strict per-app mode the stream router *moves* the chosen app's output
off the sharer's speakers into the private capture null-sink, so the
viewer heard it but the sharer went silent — you couldn't watch a video
together because only the remote side had audio.

Add a "local monitor" loopback (null-sink.monitor → @DEFAULT_SINK@) that
mirrors the routed app back to the sharer's own speakers. It carries only
the chosen app (never the desktop/voice call), so it can't echo into the
capture, and it's loaded on the first routed stream — after the default
loopback is unloaded — so the two are never live at once (no feedback).
Unloaded when the app stops and torn down before the null-sink on cleanup.

Extend `--repair` to recognise this loopback by its `source=` arg (it
targets @DEFAULT_SINK@, not a pixelpass name) so a crashed host's local
monitor is swept too. New pure `loopback_capture_pid` + 3 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:03:17 -04:00
molluskandClaude Opus 4.8 31b33e9e5a docs(deb): document the Debian .deb build environment
Companion to peerspeak's packaging/debian/README. Captures the shared bookworm
distrobox build, the box-local CARGO_TARGET_DIR, and — most importantly — why
the GStreamer capture stack is hard-coded into Depends (invoked as subprocesses,
invisible to dpkg-shlibdeps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:03:49 -04:00
molluskandClaude Opus 4.8 e16b7190bb packaging: build from public gitbutter repo instead of local path
The PKGBUILD url + source pointed at file:///home/mollusk/git/butter/pixelpass,
a local-only path no one else could build from. The repo is public on
gitbutter, so point both at the anonymous HTTPS clone URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:59:11 -04:00
molluskandClaude Opus 4.8 c39ab081d9 packaging: pull GStreamer capture stack into .deb runtime deps
pixelpass invokes the GStreamer tools and pactl as subprocesses, not as
linked libraries, so dpkg-shlibdeps (`depends = "$auto"`) never sees them.
On a fresh Ubuntu host that means `deps::check_host_binaries` bails before
the host emits its ticket — peerspeak then reports the generic "pixelpass
host exited before emitting a ticket" (first 2-human field hit, 2026-06-26).

List the runtime stack explicitly so `apt install ./pixelpass.deb` pulls in
gstreamer1.0-{tools,plugins-base,plugins-good,plugins-bad,plugins-ugly,libav,
pipewire,pulseaudio}, pulseaudio-utils and x11-utils. Recommends mpv.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:10:08 -04:00
molluskandClaude Opus 4.8 646f35d3eb host/audio: emit initial app_audio "lost" at strict capture start (A23 P2/F1)
In strict per-app mode the default-sink loopback is suppressed, so until the
chosen app's first stream routes the viewer hears silence. Previously no event
fired for an app that never routed (`lost` only fires on an N→0 transition
after a prior route), so peerspeak couldn't warn — the share looked normal but
was silent. Emit a `lost` at capture start (lazy, on first viewer) when, and
only when, `--app` + `--strict-audio` are both set; whole-desktop and
best-effort modes keep audio flowing via the loopback and emit nothing.

Factored the emit decision into the pure, unit-tested `initial_app_audio_state`;
derive Debug/PartialEq/Eq on AppAudioState so it can be asserted on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:58 -04:00
molluskandClaude Opus 4.8 ff7daee34e packaging: add cargo-deb metadata for Debian/Ubuntu .deb builds
Add a [package.metadata.deb] block so the headless default build (no `gui`
feature) — the variant peerspeak spawns as a child — can be packaged with
`cargo deb` from inside a Debian/Ubuntu distrobox. Ships only the pixelpass
binary; runtime shared-lib deps resolved by dpkg-shlibdeps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:30:17 -04:00
molluskandClaude Opus 4.8 85fdebeb66 feat(audio): add --strict-audio + app_audio route-status events
With --app, pixelpass mirrors the default-sink monitor (whole desktop) until the
chosen app's streams route, and restores that loopback if the app's audio later
stops. That fallback captures everything playing — including a voice call the
sharer is in — so a caller watching the share can hear themselves echoed back
(peerspeak bug A23: the per-app pick alone is best-effort, not a guarantee).

- New --strict-audio flag (HostOpts.strict_audio): with --app, never load the
  default-sink loopback (not at startup, not on LastRoutedStreamGone). The viewer
  hears only the chosen app, and silence when it's quiet — never the rest of the
  desktop. No effect without --app; standalone best-effort behavior is unchanged.
- New app_audio JSON event ({"event":"app_audio","state":"routed"|"lost"}),
  emitted whenever --app is set, so a front-end (peerspeak) can tell when the
  chosen app's audio is actually live vs. dropped and warn accordingly.
- Banner capture summary shows "(strict)" when active.

Unknown-event-tolerant: pixelpass's own --gui child parser skips lines it can't
deserialize, so app_audio doesn't disturb it. 10 tests (+2: wire-shape + banner),
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:44:02 -04:00
molluskandClaude Opus 4.8 cfc480044f fix: three robustness bugs outside the friends list
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>
2026-05-31 15:27:07 -04:00
molluskandClaude Opus 4.8 6d0bf99076 fix(friends): five robustness bugs in the friends/control plane
Found in a bug audit of the just-merged friends-list feature. No crashes
or security holes, but five real state/correctness bugs:

- Host child dying on its own left the share campaign running, so it kept
  pushing a now-dead ticket to friends (retrying offline ones forever) and
  leaked share_status/met/share_code. The unexpected-exit path now captures
  the stderr error, then routes through the full stop_host() teardown
  (notably stop_share). (gui/mod.rs pump_host_events)

- on_friend_request downgraded an already-Accepted friend back to
  PendingIncoming when they re-sent a request (e.g. after losing their
  store). It now stays Accepted and re-confirms. (friends.rs)

- on_friend_accept advanced *any* known peer to Accepted, including a
  PendingIncoming one — a peer could mark itself accepted without the local
  user's consent. Now only a PendingOutgoing request we sent is honoured.
  (friends.rs)

- A ShareCode redelivered by an ACK-loss retry fired a duplicate desktop
  notification. push_notice now reports whether the code is new/changed and
  only then toasts. (gui/mod.rs)

- An inbound control message could be delayed up to IO_TIMEOUT on a degraded
  link because handle() awaited the sender's close before forwarding it.
  Forward to the UI first, then await close so the ACK still flushes.
  (control.rs)

Adds two friends-store transition tests (accept ignores a pending-incoming
peer; request doesn't downgrade an accepted friend). 47 gui / 8 headless
tests pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:10:22 -04:00
21 changed files with 1216 additions and 93 deletions
+21
View File
@@ -6,6 +6,27 @@ description = "P2P screen sharing CLI over iroh"
license = "MIT OR Apache-2.0"
publish = false
# Debian/Ubuntu packaging (cargo-deb). Headless default build (no `gui` feature) —
# that is exactly what peerspeak spawns as a child. Runtime shared-lib deps
# (libpipewire, libc, …) are resolved by dpkg-shlibdeps via `depends = "$auto"`.
# Build inside a Debian/Ubuntu distrobox, then `cargo deb --no-build`.
[package.metadata.deb]
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
section = "net"
priority = "optional"
# $auto covers linked shared libs (dpkg-shlibdeps). The GStreamer capture stack
# and pactl are invoked as *subprocesses* (gst-launch-1.0 / gst-inspect-1.0 /
# pactl), so shlibdeps can't see them — list them explicitly or a fresh Ubuntu
# host bails at `deps::check_host_binaries` before emitting its ticket. Covers
# both backends: pipewiresrc (Wayland), ximagesrc (X11, in plugins-good), the
# VAAPI + software H.264 encoders, the AAC/TS mux tail, and the PulseAudio src.
depends = "$auto, gstreamer1.0-tools, gstreamer1.0-plugins-base, gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-plugins-ugly, gstreamer1.0-libav, gstreamer1.0-pipewire, gstreamer1.0-pulseaudio, pulseaudio-utils, x11-utils"
recommends = "mpv"
extended-description = "Peer-to-peer screen sharing over iroh (QUIC). Companion to peerspeak: shares a window or screen directly to a peer with no central server, driven via the CLI and its JSON event stream."
assets = [
["target/release/pixelpass", "usr/bin/", "755"],
]
[[bin]]
name = "pixelpass"
path = "src/main.rs"
+31
View File
@@ -23,6 +23,8 @@ Working:
- 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
- `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
viewer player, relay reachability) — see [Diagnostics](#diagnostics)
- 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>`)
@@ -135,6 +137,35 @@ 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.
## Diagnostics
`pixelpass --doctor` prints a one-shot report of everything the above
requirements cover and exits — run it on any machine before a real session:
```sh
pixelpass --doctor
```
It checks, and prints a `✓ / ! / ✗` line for each:
- **display server** — Wayland vs. X11 (autodetected), the raw session env
vars, and the X server's vendor/version (so an xlibre server is visible)
- **capture** — the GStreamer tools plus the source element for your backend
(`pipewiresrc` on Wayland, `ximagesrc` on X11)
- **encode** — whether hardware H.264 works (the `vah264enc` plugin, a DRM
render node, and a VA-API H.264 *encode* entrypoint via `vainfo`), and
whether the software `x264enc` fallback is available. This is the usual
culprit when a viewer "can't connect": a GPU with no H.264 encode entrypoint
produces no video under the default encoder — the report tells you to host
with `--no-hwencode`
- **mux / audio** — the TS mux + AAC + PulseAudio tail, and `pactl`
- **viewer** — whether `mpv` or `vlc` is installed
- **network** — binds a real endpoint and checks a relay is reachable
Each failing line includes a distro-aware install hint, and the closing summary
says whether the machine can host and how. The exit code is non-zero if any
hard requirement is missing, so it can gate a script or CI.
## Build
```sh
+3 -3
View File
@@ -1,6 +1,6 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Local versioned package, built from the local git repo on `main`.
# Versioned package, built from the public gitbutter repo on `main`.
# For a tagged release, switch the source fragment to `#tag=v0.1.0`.
pkgname=pixelpass
@@ -8,7 +8,7 @@ pkgver=0.1.0
pkgrel=1
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
arch=('x86_64')
url='file:///home/mollusk/git/butter/pixelpass'
url='https://gitbutter.xyz/mollusk/pixelpass'
license=('MIT' 'Apache-2.0' 'OFL-1.1')
depends=(
'gstreamer' # gst-launch-1.0 / gst-inspect-1.0
@@ -33,7 +33,7 @@ optdepends=(
makedepends=('cargo' 'git')
options=('!lto')
_branch='main'
source=("$pkgname::git+file:///home/mollusk/git/butter/pixelpass#branch=$_branch")
source=("$pkgname::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=$_branch")
sha256sums=('SKIP')
prepare() {
+63
View File
@@ -0,0 +1,63 @@
# Debian / Ubuntu `.deb` build
This documents how the `pixelpass_*.deb` is produced. The deb **recipe itself**
lives in-repo as the `[package.metadata.deb]` block in `Cargo.toml` (cargo-deb's
equivalent of a PKGBUILD); this file documents only the build environment.
pixelpass is the screen-share companion to peerspeak and is built the same way
in the same box. See peerspeak's `packaging/debian/README.md` for the full
rationale behind each step — this is the short version.
## TL;DR
```sh
distrobox enter peerspeak-bookworm -- bash -lc '
source ~/.cargo/env
cd ~/git/butter/pixelpass
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/pixelpass # MANDATORY
cargo deb
'
# output: $CARGO_TARGET_DIR/debian/pixelpass_<version>-1_amd64.deb
```
## Build environment
- **Base: the same Debian 12 (bookworm) distrobox `peerspeak-bookworm`**
(glibc 2.36) used for peerspeak. **Never build on the Arch host** (newer glibc
+ shared `$HOME`/`target/` would link Arch C objects into the binary).
- **Use a box-local, pixelpass-specific `CARGO_TARGET_DIR`** (distinct from
peerspeak's) so the two never share an artifact cache:
`export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/pixelpass`.
- Toolchain provisioning (rustup stable + `cargo-deb` + `build-essential`
`pkg-config`) is identical to peerspeak's README. pixelpass itself links few
C libraries — the heavy GStreamer stack it uses is invoked as subprocesses,
not linked (see below), so it adds no extra `*-dev` build-deps beyond the base.
## Why `Depends` lists the whole GStreamer stack explicitly
pixelpass does its screen capture by shelling out to the GStreamer CLI
(`gst-launch-1.0` / `gst-inspect-1.0`) and to `pactl`, **not** by linking the
GStreamer libraries. That means `dpkg-shlibdeps` (which only sees linked `.so`
files) cannot detect them, so `$auto` alone would ship a `.deb` whose `Depends`
omits the entire capture stack. A fresh Ubuntu host would then fail at
pixelpass's own `deps::check_host_binaries` startup probe — *before* it ever
prints a connection ticket, which is exactly the field bug that motivated this.
So the `Cargo.toml` `depends` hard-codes the runtime stack on top of `$auto`:
```
$auto, gstreamer1.0-tools, gstreamer1.0-plugins-base,
gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad,
gstreamer1.0-plugins-ugly, gstreamer1.0-libav, gstreamer1.0-pipewire,
gstreamer1.0-pulseaudio, pulseaudio-utils, x11-utils
```
This covers both capture backends (`pipewiresrc` on Wayland, `ximagesrc` on X11
from plugins-good), the VAAPI + software H.264 encoders, the AAC/TS mux tail,
the PulseAudio source, and the `pactl`/`xdpyinfo` helpers.
## glibc floor
Same as peerspeak: built against glibc 2.36 → runs on Debian 12+ / Ubuntu
24.04+. (pixelpass's own linked-library floor is lower, ~2.39-era, but it is
always shipped alongside peerspeak, whose 2.36 floor governs the pair.)
+24
View File
@@ -27,6 +27,17 @@ pub struct Cli {
#[arg(long, value_name = "NAME")]
pub app: Option<String>,
/// With `--app`, never fall back to whole-desktop audio. By default an
/// app-filtered host mirrors the default sink's monitor until (and again
/// after) the chosen app's streams route, so the viewer isn't left in
/// silence. That fallback also captures everything else playing — including
/// a voice call the sharer is in — so a caller can hear themselves echoed.
/// `--strict-audio` suppresses the fallback entirely: the viewer hears only
/// the chosen app, and silence when it isn't producing audio. Ignored
/// without `--app`.
#[arg(long)]
pub strict_audio: bool,
/// Override display server autodetection.
#[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>,
@@ -94,6 +105,14 @@ pub struct Cli {
#[arg(long)]
pub repair: bool,
/// Print an environment diagnostic report (display server, capture/encode
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
/// then exit. Use this to check a machine can host or view before a real
/// session — especially to confirm hardware H.264 encode works, since a GPU
/// without it silently produces no video under the default encoder.
#[arg(long)]
pub doctor: bool,
/// Re-run the bandwidth pre-flight test, save the result, then exit.
/// Use this if your connection has changed (new ISP, moved house, etc.)
/// or if the previously saved test result is stale.
@@ -135,6 +154,10 @@ pub enum Quality {
pub struct HostOpts {
pub window: bool,
pub app: Option<String>,
/// With `app` set, suppress the whole-desktop loopback fallback so the
/// viewer only ever hears the chosen app (silence when it's quiet). No
/// effect when `app` is None.
pub strict_audio: bool,
pub display_server: Option<DisplayServerArg>,
/// Chosen preset (Auto = derive at startup). Defaults to Auto.
pub quality: Quality,
@@ -164,6 +187,7 @@ impl Cli {
HostOpts {
window: self.window,
app: self.app,
strict_audio: self.strict_audio,
display_server: self.display_server,
// No `--quality` and nothing picked interactively → the documented
// default, Auto.
+7 -4
View File
@@ -166,13 +166,16 @@ async fn handle(incoming: Incoming, tx: &mpsc::Sender<Inbound>) -> Result<()> {
.await
.context("timed out reading control message")??;
// Wait (briefly) for the sender's close so our ACK flushes before the
// connection is dropped at the end of this scope.
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
// Hand the message up first, so it reaches the UI promptly even when the
// sender is slow to close (a degraded link could otherwise delay a friend
// request / pushed code by up to IO_TIMEOUT).
tx.send(Inbound { from, msg })
.await
.map_err(|_| anyhow::anyhow!("control: receiver dropped"))?;
// Then wait (briefly) for the sender's close so our ACK has flushed before
// the connection is dropped at the end of this scope.
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
Ok(())
}
+15 -10
View File
@@ -56,12 +56,7 @@ fn require(bin: &str) -> Result<PathBuf> {
}
fn require_gst_element(name: &str) -> Result<()> {
let ok = Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
if !gst_element_exists(name) {
bail!(
"GStreamer element `{name}` not available.\n{}",
install_hint_for_gst_element(name)
@@ -70,7 +65,17 @@ fn require_gst_element(name: &str) -> Result<()> {
Ok(())
}
fn which(bin: &str) -> Option<PathBuf> {
/// Whether a GStreamer element is registered, via `gst-inspect-1.0 --exists`.
/// Non-bailing counterpart to [`require_gst_element`] for the `doctor` report.
pub(crate) fn gst_element_exists(name: &str) -> bool {
Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(crate) fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
@@ -81,7 +86,7 @@ fn which(bin: &str) -> Option<PathBuf> {
None
}
fn install_hint_for_bin(bin: &str) -> String {
pub(crate) fn install_hint_for_bin(bin: &str) -> String {
let distro = detect_distro();
let pkg = match bin {
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
@@ -113,7 +118,7 @@ fn install_hint_for_bin(bin: &str) -> String {
install_command(&distro, pkg)
}
fn install_hint_for_gst_element(name: &str) -> String {
pub(crate) fn install_hint_for_gst_element(name: &str) -> String {
let distro = detect_distro();
let pkg = match name {
"pipewiresrc" => match distro.as_deref() {
@@ -210,7 +215,7 @@ fn install_command(distro: &Option<String>, pkg: &str) -> String {
format!("Install hint: {cmd}")
}
fn detect_distro() -> Option<String> {
pub(crate) fn detect_distro() -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("ID=") {
+47 -12
View File
@@ -147,33 +147,43 @@ impl FriendStore {
self.friends.len() != before
}
/// Apply an inbound friend request. Returns `true` if it *completes a mutual
/// match* — we'd already sent them one, so they're now [`Accepted`] and the
/// caller should reply with a `FriendAccept`. Otherwise it's recorded as
/// Apply an inbound friend request. Returns `true` if the friendship is now
/// settled at [`Accepted`] and the caller should reply with a `FriendAccept`
/// — either because we'd already sent them a request (a mutual match) or
/// because they're an existing friend re-announcing (we never downgrade an
/// [`Accepted`] friend back to pending; a peer who lost their store and
/// re-adds us just gets re-confirmed). Otherwise it's recorded as
/// [`PendingIncoming`] for the user to act on and `false` is returned.
///
/// [`Accepted`]: FriendState::Accepted
/// [`PendingIncoming`]: FriendState::PendingIncoming
pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool {
if matches!(
self.find(&id).map(|f| f.state),
Some(FriendState::PendingOutgoing)
) {
match self.find(&id).map(|f| f.state) {
Some(FriendState::PendingOutgoing | FriendState::Accepted) => {
self.upsert(id, name, FriendState::Accepted);
true
} else {
}
_ => {
self.upsert(id, name, FriendState::PendingIncoming);
false
}
}
}
/// Apply an inbound acceptance of a request we sent. Returns `true` if it
/// advanced a friendship to [`Accepted`] (i.e. we actually knew this peer);
/// an accept from a stranger is ignored.
/// Apply an inbound acceptance of a request we sent. Returns `true` only if
/// it advanced one of *our* outgoing requests to [`Accepted`]. An accept for
/// any other state is ignored: a stranger's, or one for a peer still in
/// [`PendingIncoming`] (their request, awaiting our decision) — honouring the
/// latter would let a peer mark itself accepted without the local user's
/// consent.
///
/// [`Accepted`]: FriendState::Accepted
/// [`PendingIncoming`]: FriendState::PendingIncoming
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
if self.find(&id).is_some() {
if matches!(
self.find(&id).map(|f| f.state),
Some(FriendState::PendingOutgoing)
) {
self.upsert(id, name, FriendState::Accepted);
true
} else {
@@ -293,4 +303,29 @@ mod tests {
assert!(!store.on_friend_accept(stranger, "Nope".into()));
assert!(store.find(&stranger).is_none());
}
#[test]
fn accept_does_not_advance_a_pending_incoming_peer() {
// They asked us and we haven't decided yet; an unsolicited FriendAccept
// from them must not auto-accept on our behalf (consent bypass).
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Theirs".into(), FriendState::PendingIncoming);
assert!(!store.on_friend_accept(id, "Theirs".into()));
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
}
#[test]
fn request_does_not_downgrade_an_accepted_friend() {
// A current friend re-sending a request (e.g. after losing their store)
// must stay accepted; the call signals a re-confirm rather than a
// downgrade to pending.
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Pal".into(), FriendState::Accepted);
let settled = store.on_friend_request(id, "Pal (reinstalled)".into());
assert!(settled);
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
assert_eq!(store.find(&id).unwrap().name, "Pal (reinstalled)");
}
}
+34
View File
@@ -58,6 +58,11 @@ pub enum Event<'a> {
ViewerRefused { reason: &'a str },
/// Viewer-side: the local player URL is ready to open.
Connected { url: &'a str },
/// Per-app audio routing state (only emitted when `--app` is set). `routed`
/// = the chosen app's audio is now reaching viewers; `lost` = its last
/// stream went away. Under `--strict-audio`, `lost` means viewers currently
/// hear silence; without it, viewers fall back to whole-desktop audio.
AppAudio { state: AppAudioState },
}
#[derive(Serialize)]
@@ -67,6 +72,13 @@ pub enum CaptureState {
Stopped,
}
#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AppAudioState {
Routed,
Lost,
}
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
/// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching.
@@ -85,3 +97,25 @@ pub fn emit(event: Event) {
Err(e) => tracing::warn!("failed to serialize event: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
// The app_audio event is the wire contract peerspeak parses to drive its
// echo warning; pin the exact shape so a rename here is caught here.
#[test]
fn app_audio_event_wire_shape() {
let routed = serde_json::to_string(&Event::AppAudio {
state: AppAudioState::Routed,
})
.unwrap();
assert_eq!(routed, r#"{"event":"app_audio","state":"routed"}"#);
let lost = serde_json::to_string(&Event::AppAudio {
state: AppAudioState::Lost,
})
.unwrap();
assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#);
}
}
+18 -5
View File
@@ -6,10 +6,19 @@ use std::process::{Command, Stdio};
///
/// The child gets its own session via `setsid(2)` and null stdio, so it
/// survives the parent exiting and doesn't take a SIGKILL cascade when
/// pixelpass dies. The `Child` is dropped immediately — `std::process::Child::drop`
/// does not kill the process on Unix.
/// pixelpass dies.
///
/// A detached reaper thread `wait()`s the child so it doesn't linger as a
/// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches
/// players itself and lives for the whole session, and `std::process::Child`
/// (unlike tokio's) has no orphan reaping, so simply dropping the handle would
/// leak a zombie per closed player. If the parent exits while the player is
/// still up, the reaper thread dies with it but the `setsid`'d player survives
/// and is reaped by init. (A double-fork would also avoid the zombie, but
/// `fork(2)` followed by non-trivial work in this multithreaded process is
/// unsound — the reaper thread is the safe equivalent.)
pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
unsafe {
let child = unsafe {
Command::new(prog)
.args(args)
.stdin(Stdio::null())
@@ -19,7 +28,11 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
nix::unistd::setsid().ok();
Ok(())
})
.spawn()?;
}
.spawn()?
};
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
Ok(())
}
+621
View File
@@ -0,0 +1,621 @@
//! `pixelpass doctor` — environment diagnostics.
//!
//! Screen-share failures are usually not pixelpass bugs but environment gaps:
//! a missing GStreamer plugin, an X vs. Wayland mismatch, or — the common one —
//! a GPU/driver with no working VA-API H.264 encoder, so the default
//! `vah264enc` pipeline never produces a byte and the viewer "can't connect."
//! `doctor` probes all of that up front and prints one actionable report, so a
//! remote tester can read it over a call instead of us guessing from logs. It
//! also validates any X11/Wayland test environment we stand up.
//!
//! Unlike [`crate::common::deps::check_host_binaries`], which bails on the first
//! missing dependency, doctor runs *every* check and reports them together — a
//! diagnostic wants the whole picture, not the first failure.
use anyhow::Result;
use std::time::Duration;
use crate::common::deps;
use crate::common::display::DisplayServer;
use crate::common::endpoint;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// Working as needed.
Ok,
/// Degraded but not fatal (e.g. a fallback path is available).
Warn,
/// Screen-sharing will not work until this is fixed.
Fail,
/// Neutral fact, no judgement.
Info,
}
impl Status {
fn icon(self) -> char {
match self {
Self::Ok => '✓',
Self::Warn => '!',
Self::Fail => '✗',
Self::Info => '·',
}
}
}
/// One line in the report: a status, a short label, a detail, and an optional
/// remediation hint printed on its own indented line.
pub struct Check {
pub status: Status,
pub label: String,
pub detail: String,
pub hint: Option<String>,
}
impl Check {
fn new(status: Status, label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status,
label: label.into(),
detail: detail.into(),
hint: None,
}
}
fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Ok, label, detail)
}
fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Warn, label, detail)
}
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Fail, label, detail)
}
fn info(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Info, label, detail)
}
fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
}
/// Tally of the non-trivial statuses across every section.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Summary {
pub fails: usize,
pub warns: usize,
}
/// A named group of checks, printed under a header.
struct Section {
name: &'static str,
checks: Vec<Check>,
}
/// Run all diagnostics and print the report. Always prints; the process exit
/// code is non-zero only when a hard failure (a `Fail`) was found, so scripts
/// and CI can gate on it while a human still sees everything.
pub async fn run(relay: Option<String>) -> Result<()> {
let display = DisplayServer::detect();
let sections = vec![
system_section(display),
capture_section(display),
encode_section(),
mux_audio_section(),
viewer_section(),
network_section(relay.as_deref()).await,
];
print_report(&sections);
let summary = summarize(sections.iter().flat_map(|s| s.checks.iter()));
print_summary(summary, &sections);
if summary.fails > 0 {
std::process::exit(1);
}
Ok(())
}
// ── sections ──────────────────────────────────────────────────────────────
fn system_section(display: DisplayServer) -> Section {
let mut checks = vec![
Check::info(
"pixelpass",
format!("{} (gui: {})", env!("CARGO_PKG_VERSION"), gui_built()),
),
Check::info("distro", distro_detail()),
display_check(display),
];
// Probe the actual X server when one is reachable — this is where an xlibre
// vs. Xorg difference (the thing we most want to see on a tester's box)
// shows up. Skip it on a pure Wayland session with no X at all.
if display == DisplayServer::X11 || std::env::var_os("DISPLAY").is_some() {
checks.push(x_server_check());
}
Section {
name: "System",
checks,
}
}
fn capture_section(display: DisplayServer) -> Section {
let mut checks = vec![
bin_check("gst-launch-1.0", "gstreamer tools"),
bin_check("gst-inspect-1.0", "gstreamer tools"),
];
match display {
DisplayServer::Wayland => {
checks.push(gst_check("pipewiresrc", "Wayland capture"));
}
DisplayServer::X11 => {
checks.push(gst_check("ximagesrc", "X11 capture"));
checks.push(match deps::which("xwininfo") {
Some(p) => Check::ok("window picker", p.display().to_string())
.with_hint("needed only for `--window` (share a single window)"),
None => Check::info("window picker", "xwininfo not found")
.with_hint("optional — only `--window` needs it"),
});
}
DisplayServer::Unknown => {
checks.push(
Check::info("capture backend", "unknown — cannot probe a source element")
.with_hint("force one with `--display-server x11|wayland` when hosting"),
);
}
}
Section {
name: "Capture (host)",
checks,
}
}
fn encode_section() -> Section {
Section {
name: "Encode",
checks: vec![hardware_encode_check(), software_encode_check()],
}
}
/// The load-bearing check for the common "viewer can't connect" report: the
/// default host pipeline uses `vah264enc`, which needs both the GStreamer VA
/// plugin *and* a GPU/driver that actually exposes an H.264 encode entrypoint.
/// A box with the plugin but no encode entrypoint (or no render node) produces
/// no video — the exact silent failure `--no-hwencode` works around.
fn hardware_encode_check() -> Check {
if !deps::gst_element_exists("vah264enc") {
return Check::warn("hardware H.264", "vah264enc plugin not installed")
.with_hint(format!(
"{} — or just host with `--no-hwencode` (software x264)",
deps::install_hint_for_gst_element("vah264enc")
));
}
if !has_render_node() {
return Check::warn(
"hardware H.264",
"vah264enc present, but no DRM render node (/dev/dri/renderD*)",
)
.with_hint("GPU encode is unavailable here — host with `--no-hwencode`");
}
match vainfo_output() {
Some(out) if vainfo_has_h264_encode(&out) => {
Check::ok("hardware H.264", "VA-API H.264 encode available (vah264enc)")
}
Some(_) => Check::warn(
"hardware H.264",
"vah264enc present, but VA-API reports no H.264 encode entrypoint",
)
.with_hint("this GPU/driver can't hardware-encode H.264 — host with `--no-hwencode`"),
None => Check::info(
"hardware H.264",
"vah264enc + render node present; couldn't confirm the VA-API encode entrypoint",
)
.with_hint("install `vainfo` (libva-utils) to verify, or just test a real host session"),
}
}
fn software_encode_check() -> Check {
if deps::gst_element_exists("x264enc") {
Check::ok("software H.264", "x264enc available (`--no-hwencode`)")
} else {
Check::warn("software H.264", "x264enc not installed").with_hint(format!(
"{} — the fallback for GPUs without VA-API H.264 encode",
deps::install_hint_for_gst_element("x264enc")
))
}
}
fn mux_audio_section() -> Section {
// These live in plugins-bad/-good/-libav and plugins-base; all are required
// for either backend, so a miss here is a hard Fail.
let tail = ["h264parse", "mpegtsmux", "aacparse", "avenc_aac", "pulsesrc", "videoscale"];
let missing: Vec<&str> = tail.iter().copied().filter(|e| !deps::gst_element_exists(e)).collect();
let tail_check = if missing.is_empty() {
Check::ok("mux + audio tail", tail.join(", "))
} else {
Check::fail("mux + audio tail", format!("missing: {}", missing.join(", "))).with_hint(
deps::install_hint_for_gst_element(missing[0]),
)
};
Section {
name: "Mux / audio",
checks: vec![tail_check, bin_check("pactl", "pactl")],
}
}
fn viewer_section() -> Section {
let mpv = deps::which("mpv");
let vlc = deps::which("vlc");
let check = match (mpv, vlc) {
(Some(p), _) => Check::ok("player", format!("mpv ({})", p.display())),
(None, Some(p)) => Check::ok("player", format!("vlc ({})", p.display()))
.with_hint("mpv is the recommended player; vlc needs the dvb + ffmpeg plugins"),
(None, None) => Check::warn("player", "neither mpv nor vlc found")
.with_hint("a viewer needs one of them; the GUI launches mpv by default"),
};
Section {
name: "Viewer",
checks: vec![check],
}
}
/// Bind a real video-plane endpoint and wait briefly for a relay, mirroring
/// what a host does. Directly relevant to "couldn't connect": if this machine
/// can't reach a relay, hole-punching to a peer is unlikely to work either.
async fn network_section(relay: Option<&str>) -> Section {
let check = match endpoint::bind(relay).await {
Ok(ep) => {
let online = tokio::time::timeout(Duration::from_secs(8), ep.online()).await.is_ok();
let relay_count = ep.addr().addrs.iter().filter(|a| a.is_relay()).count();
let where_ = relay.map(|r| format!(" ({r})")).unwrap_or_default();
// Close gracefully so iroh doesn't log a scary "Endpoint dropped
// without calling close" error into the middle of the report.
ep.close().await;
if online && relay_count > 0 {
Check::ok("relay", format!("home relay reachable{where_}"))
} else if online {
Check::warn("relay", format!("endpoint online but no relay address{where_}"))
.with_hint("n0 DNS discovery may still connect peers, but relay fallback is degraded")
} else {
Check::warn("relay", format!("no relay connected within 8s{where_}"))
.with_hint("check connectivity/firewall; peers behind NAT rely on the relay to rendezvous")
}
}
Err(e) => Check::fail("relay", format!("could not bind endpoint: {e}")),
};
Section {
name: "Network",
checks: vec![check],
}
}
// ── small check builders ────────────────────────────────────────────────────
fn bin_check(bin: &str, label: &str) -> Check {
match deps::which(bin) {
Some(p) => Check::ok(label, format!("{bin} ({})", p.display())),
None => Check::fail(label, format!("{bin} not found on PATH"))
.with_hint(deps::install_hint_for_bin(bin)),
}
}
fn gst_check(element: &str, label: &str) -> Check {
if deps::gst_element_exists(element) {
Check::ok(label, element.to_string())
} else {
Check::fail(label, format!("GStreamer element `{element}` not available"))
.with_hint(deps::install_hint_for_gst_element(element))
}
}
fn display_check(display: DisplayServer) -> Check {
let env = display_env_summary();
match display {
DisplayServer::Wayland => Check::ok("display server", format!("Wayland ({env})")),
DisplayServer::X11 => Check::ok("display server", format!("X11 ({env})")),
DisplayServer::Unknown => Check::fail("display server", format!("undetected ({env})"))
.with_hint(
"no WAYLAND_DISPLAY/DISPLAY/XDG_SESSION_TYPE — capture can't start; \
run inside a graphical session or pass `--display-server`",
),
}
}
/// Connect to the X server and report its vendor + version. This is how an
/// xlibre server distinguishes itself from stock Xorg (vendor string / release
/// number), which is exactly what we want to see on a tester's machine.
fn x_server_check() -> Check {
use x11rb::connection::Connection;
match x11rb::connect(None) {
Ok((conn, _screen)) => {
let setup = conn.setup();
let vendor = String::from_utf8_lossy(&setup.vendor);
let detail = format!(
"vendor \"{}\", protocol {}.{}, release {}",
vendor.trim(),
setup.protocol_major_version,
setup.protocol_minor_version,
setup.release_number,
);
let label = "X server";
if vendor.to_lowercase().contains("xlibre") {
Check::info(label, format!("XLibre — {detail}"))
} else {
Check::info(label, detail)
}
}
Err(_) => Check::info("X server", "DISPLAY set but the X server is unreachable"),
}
}
// ── environment helpers ─────────────────────────────────────────────────────
fn gui_built() -> &'static str {
if cfg!(feature = "gui") { "yes" } else { "no" }
}
fn distro_detail() -> String {
let id = deps::detect_distro();
let pretty = os_release_field("PRETTY_NAME");
match (id, pretty) {
(Some(id), Some(p)) => format!("{id} ({p})"),
(Some(id), None) => id,
(None, Some(p)) => p,
(None, None) => "unknown".to_string(),
}
}
fn os_release_field(key: &str) -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix(&format!("{key}=")) {
return Some(rest.trim_matches('"').to_string());
}
}
None
}
fn display_env_summary() -> String {
let mut parts = Vec::new();
for var in ["WAYLAND_DISPLAY", "DISPLAY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP"] {
if let Some(v) = std::env::var_os(var) {
parts.push(format!("{var}={}", v.to_string_lossy()));
}
}
if parts.is_empty() {
"no display env vars set".to_string()
} else {
parts.join(", ")
}
}
fn has_render_node() -> bool {
let Ok(entries) = std::fs::read_dir("/dev/dri") else {
return false;
};
entries.flatten().any(|e| {
e.file_name()
.to_string_lossy()
.starts_with("renderD")
})
}
fn vainfo_output() -> Option<String> {
deps::which("vainfo")?;
let out = std::process::Command::new("vainfo").output().ok()?;
// vainfo prints its profile/entrypoint table to stdout; some builds also
// spill driver banners to stderr. Concatenate both so parsing is robust.
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
Some(s)
}
/// Pure: does a `vainfo` dump advertise an H.264 *encode* entrypoint? vainfo
/// lists one `VAProfile… : VAEntrypoint…` pair per line; hardware H.264 encode
/// is any `VAProfileH264*` profile paired with an `EncSlice`/`EncSliceLP`
/// entrypoint. VLD-only H.264 (decode) does not count.
fn vainfo_has_h264_encode(output: &str) -> bool {
output.lines().any(|line| {
line.contains("VAProfileH264")
&& (line.contains("VAEntrypointEncSlice") || line.contains("VAEntrypointEncSliceLP"))
})
}
// ── reporting ───────────────────────────────────────────────────────────────
fn print_report(sections: &[Section]) {
println!("pixelpass doctor\n");
for section in sections {
println!("{}", section.name);
for check in &section.checks {
println!(
" {} {:<16} {}",
check.status.icon(),
check.label,
check.detail
);
if let Some(hint) = &check.hint {
println!("{hint}");
}
}
println!();
}
}
fn summarize<'a>(checks: impl Iterator<Item = &'a Check>) -> Summary {
let mut summary = Summary::default();
for check in checks {
match check.status {
Status::Fail => summary.fails += 1,
Status::Warn => summary.warns += 1,
Status::Ok | Status::Info => {}
}
}
summary
}
fn print_summary(summary: Summary, sections: &[Section]) {
let hosting = hosting_verdict(sections);
let counts = match (summary.fails, summary.warns) {
(0, 0) => "all checks passed".to_string(),
(0, w) => format!("{w} warning{}", plural(w)),
(f, 0) => format!("{f} failure{}", plural(f)),
(f, w) => format!("{f} failure{}, {w} warning{}", plural(f), plural(w)),
};
println!("Summary: {counts}. {hosting}");
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
/// A one-line verdict on whether this box can host, and how. Reads the actual
/// encode + capture checks rather than the raw tally so the advice is specific.
fn hosting_verdict(sections: &[Section]) -> String {
let find = |section: &str, label: &str| -> Option<Status> {
sections
.iter()
.find(|s| s.name == section)?
.checks
.iter()
.find(|c| c.label == label)
.map(|c| c.status)
};
let hw = find("Encode", "hardware H.264");
let sw_ok = find("Encode", "software H.264") == Some(Status::Ok);
let capture_broken = sections
.iter()
.find(|s| s.name == "Capture (host)")
.map(|s| s.checks.iter().any(|c| c.status == Status::Fail))
.unwrap_or(false);
if capture_broken {
"Hosting will fail: the capture backend is incomplete (see Capture above).".to_string()
} else if hw == Some(Status::Ok) {
"Hosting will work (hardware H.264 encode).".to_string()
} else if hw == Some(Status::Info) && sw_ok {
// Plugin + render node present but VA-API unverified (no vainfo): the
// default encoder is likely fine; `--no-hwencode` is the safe fallback.
"Hosting should work (hardware H.264 likely; `--no-hwencode` is the fallback).".to_string()
} else if sw_ok {
"Hosting should work with `--no-hwencode` (software H.264 encode).".to_string()
} else {
"Hosting may fail: no working H.264 encoder found (see Encode above).".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vainfo_detects_h264_encode_entrypoint() {
// Realistic AMD/RADV-style dump: H.264 has both decode (VLD) and encode.
let dump = "\
VAProfileH264Main : VAEntrypointVLD
VAProfileH264Main : VAEntrypointEncSlice
VAProfileH264High : VAEntrypointVLD
VAProfileHEVCMain : VAEntrypointEncSlice";
assert!(vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_low_power_encode_counts() {
let dump = "VAProfileH264ConstrainedBaseline: VAEntrypointEncSliceLP";
assert!(vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_decode_only_h264_is_not_encode() {
// Decode-only H.264 (VLD) plus HEVC encode must NOT be read as H.264
// encode — this is exactly the "default encoder fails" case.
let dump = "\
VAProfileH264Main : VAEntrypointVLD
VAProfileH264High : VAEntrypointVLD
VAProfileHEVCMain : VAEntrypointEncSlice";
assert!(!vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_empty_is_not_encode() {
assert!(!vainfo_has_h264_encode(""));
}
#[test]
fn summarize_counts_fails_and_warns_only() {
let checks = vec![
Check::ok("a", "x"),
Check::info("b", "x"),
Check::warn("c", "x"),
Check::warn("d", "x"),
Check::fail("e", "x"),
];
let summary = summarize(checks.iter());
assert_eq!(summary, Summary { fails: 1, warns: 2 });
}
#[test]
fn hosting_verdict_prefers_hardware_then_software() {
let hw = vec![Section {
name: "Encode",
checks: vec![
Check::ok("hardware H.264", "ok"),
Check::ok("software H.264", "ok"),
],
}];
assert!(hosting_verdict(&hw).contains("hardware"));
let sw = vec![Section {
name: "Encode",
checks: vec![
Check::warn("hardware H.264", "no"),
Check::ok("software H.264", "ok"),
],
}];
assert!(sw_verdict_uses_no_hwencode(&hosting_verdict(&sw)));
let none = vec![Section {
name: "Encode",
checks: vec![
Check::warn("hardware H.264", "no"),
Check::warn("software H.264", "no"),
],
}];
assert!(hosting_verdict(&none).contains("may fail"));
}
fn sw_verdict_uses_no_hwencode(v: &str) -> bool {
v.contains("--no-hwencode")
}
#[test]
fn capture_failure_dominates_verdict() {
let sections = vec![
Section {
name: "Capture (host)",
checks: vec![Check::fail("X11 capture", "missing")],
},
Section {
name: "Encode",
checks: vec![Check::ok("hardware H.264", "ok")],
},
];
assert!(hosting_verdict(&sections).contains("capture"));
}
}
+32 -12
View File
@@ -1145,11 +1145,14 @@ impl PixelPassApp {
f.name = name.clone();
store_changed = true;
}
self.push_notice(from, name.clone(), ticket);
// Only toast for a new/changed code — an ACK-loss retry
// redelivers the same code and shouldn't fire again.
if self.push_notice(from, name.clone(), ticket) {
notify(
"PixelPass — a friend is sharing",
format!("{name} is sharing their screen. Open PixelPass to watch."),
);
}
} else {
tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend");
}
@@ -1197,13 +1200,20 @@ impl PixelPassApp {
}
/// Record a share code a friend pushed us, replacing any prior notice from
/// the same friend (their previous code is stale once they re-host).
fn push_notice(&mut self, from: iroh::EndpointId, name: String, code: String) {
/// the same friend (their previous code is stale once they re-host). Returns
/// `true` if this is a new notice or a *different* code than we already had
/// from them — i.e. worth a fresh desktop notification. A duplicate delivery
/// (an ACK-loss retry redelivering the same code) updates in place and
/// returns `false`, so it doesn't fire a second toast.
fn push_notice(&mut self, from: iroh::EndpointId, name: String, code: String) -> bool {
if let Some(n) = self.notices.iter_mut().find(|n| n.from == from) {
let changed = n.code != code;
n.name = name;
n.code = code;
changed
} else {
self.notices.push(ShareNotice { from, name, code });
true
}
}
@@ -2318,19 +2328,29 @@ impl PixelPassApp {
self.apply_host_event(ev);
}
if let Some(p) = &mut self.host.proc
&& !p.is_alive()
{
if self.host.ticket.is_none() {
let tail = p.stderr_tail();
self.host.error = Some(if tail.trim().is_empty() {
let dead = self.host.proc.as_mut().is_some_and(|p| !p.is_alive());
if dead {
// If it never reached a ticket, capture why (from the stderr tail)
// before tearing down. Then run the *full* Stop cleanup — most
// importantly stop_share, so a host that died on its own stops
// pushing its now-dead code to friends. Without this the campaign
// would keep retrying offline friends with a stale ticket for the
// life of the GUI, and share_status/met/share_code would leak.
let error = self.host.ticket.is_none().then(|| {
let tail = self
.host
.proc
.as_mut()
.map(|p| p.stderr_tail())
.unwrap_or_default();
if tail.trim().is_empty() {
"Host exited before it could start.".to_string()
} else {
format!("Host exited before it could start:\n{tail}")
});
}
self.host.proc = None;
self.host.capturing = false;
});
self.stop_host();
self.host.error = error;
}
}
+123 -8
View File
@@ -17,6 +17,15 @@
//! filtered audio twice (once via the routed stream, once via the
//! default-sink monitor loopback).
//!
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
//! the chosen app off the sharer's speakers into the null-sink, so without
//! this the sharer would go deaf to the very content they're sharing. We
//! mirror the null-sink's monitor back to `@DEFAULT_SINK@` so the sharer
//! hears it too. Only the chosen app is in the null-sink — never the
//! desktop/call — so this can't echo back into the capture. It is loaded on
//! the first routed stream (after the default-sink loopback is gone, so the
//! two never coexist and feed back) and unloaded when the app stops.
//!
//! pactl is the right tool for the one-shot null-sink/loopback graph
//! mutations. libpipewire is dragged in only when per-stream filtering
//! is requested, because that needs registry-event subscription.
@@ -40,6 +49,11 @@ pub struct Routing {
/// first successful route. `Routing::shutdown` unloads whatever
/// remains.
loopback_module: Arc<Mutex<Option<u32>>>,
/// The `null-sink.monitor → @DEFAULT_SINK@` loopback that lets the sharer
/// hear the routed app. Shared with the event task, which loads it on the
/// first routed stream and unloads it when the app stops. `None` outside
/// app mode and whenever no app is currently routed.
local_monitor_module: Arc<Mutex<Option<u32>>>,
sink_name: String,
stream_router: Option<StreamRouter>,
event_task: Option<tokio::task::JoinHandle<()>>,
@@ -55,27 +69,43 @@ impl Routing {
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
.context("failed to load module-null-sink")?;
// In strict per-app mode we never mirror the default sink: the viewer
// must hear *only* the chosen app, never the whole desktop (which would
// leak e.g. a voice call the sharer is in back to viewers — the echo
// bug A23). Without strict mode (whole-desktop share, or best-effort
// app filtering) we load the monitor loopback so the viewer hears
// system audio immediately and during any gap before the app routes.
// 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible.
let loopback_module = load_module(&[
let strict_app = opts.app.is_some() && opts.strict_audio;
let loopback_module = if strict_app {
None
} else {
Some(
load_module(&[
"module-loopback",
"source=@DEFAULT_SINK@.monitor",
&format!("sink={sink_name}"),
"latency_msec=20",
])
.context("failed to load module-loopback (null-sink will be cleaned up on Drop)")?;
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
)
};
tracing::info!(
sink_module,
loopback_module,
?loopback_module,
strict_app,
%sink_name,
"audio routing: null-sink + loopback ready"
"audio routing: null-sink ready (loopback skipped in strict app mode)"
);
let loopback_arc = Arc::new(Mutex::new(Some(loopback_module)));
let loopback_arc = Arc::new(Mutex::new(loopback_module));
let local_monitor_arc = Arc::new(Mutex::new(None));
let mut routing = Self {
sink_module: Some(sink_module),
loopback_module: Arc::clone(&loopback_arc),
local_monitor_module: Arc::clone(&local_monitor_arc),
sink_name: sink_name.clone(),
stream_router: None,
event_task: None,
@@ -84,8 +114,11 @@ impl Routing {
if let Some(app) = &opts.app {
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
let loopback_for_task = Arc::clone(&loopback_arc);
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
let sink_name_for_task = sink_name.clone();
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await {
match ev {
Event::FirstRoutedStream => {
@@ -96,11 +129,66 @@ impl Routing {
);
unload_module(id);
}
// Mirror the routed app back to the sharer's own
// speakers so they hear the content they're sharing.
// Loaded *after* the default-sink loopback is gone so
// the two never coexist (which would feed back), and
// sourced from the null-sink monitor — the chosen app
// only, never the desktop/call — so it can't echo into
// the capture.
if local_monitor_for_task.lock().unwrap().is_none() {
match load_module(&[
"module-loopback",
&format!("source={sink_name_for_task}.monitor"),
"sink=@DEFAULT_SINK@",
"latency_msec=20",
]) {
Ok(id) => {
tracing::info!(
module = id,
"audio routing: local monitor loaded (sharer hears the shared app)"
);
*local_monitor_for_task.lock().unwrap() = Some(id);
}
Err(e) => tracing::warn!(
"audio routing: failed to load local monitor loopback: {e:#}"
),
}
}
// Tell the front-end the chosen app's audio is live.
output::emit(output::Event::AppAudio {
state: AppAudioState::Routed,
});
}
Event::LastRoutedStreamGone => {
// Routed app exited mid-session. Restore the
// default-sink loopback so the viewer hears
// system audio again instead of silence.
// Routed app exited/paused mid-session. Notify the
// front-end either way; the recovery differs by mode.
output::emit(output::Event::AppAudio {
state: AppAudioState::Lost,
});
// The shared app is gone, so its null-sink is silent:
// stop mirroring it to the sharer's speakers. Re-loads
// on the next FirstRoutedStream if the app resumes.
if let Some(id) = local_monitor_for_task.lock().unwrap().take() {
tracing::info!(
module = id,
"audio routing: last routed stream gone → unloading local monitor"
);
unload_module(id);
}
if strict {
// Strict mode: do NOT restore the whole-desktop
// loopback. Viewers hear silence until the app
// produces audio again — never the rest of the
// desktop (call included).
tracing::info!(
"audio routing: strict mode — last routed stream gone, leaving viewers silent"
);
continue;
}
// Best-effort mode: restore the default-sink loopback
// so the viewer hears system audio again instead of
// silence.
if loopback_for_task.lock().unwrap().is_some() {
continue;
}
@@ -130,6 +218,16 @@ impl Routing {
routing.event_task = Some(event_task);
}
// Strict per-app mode suppresses the default-sink loopback, so until the
// chosen app's first stream routes the viewer hears *silence*. Emit an
// initial `lost` at capture start (capture is lazy — this runs on the
// first viewer) so the front-end can warn from the outset rather than
// only after an app that *was* routed later stops (audit A23 P2/F1):
// `LastRoutedStreamGone`→`lost` never fires for an app that never routed.
if let Some(state) = initial_app_audio_state(opts) {
crate::common::output::emit(crate::common::output::Event::AppAudio { state });
}
Ok(routing)
}
@@ -153,6 +251,11 @@ impl Routing {
if let Some(id) = self.loopback_module.lock().unwrap().take() {
unload_module(id);
}
// Unload the local monitor before the null-sink it reads from, so the
// sink has no active loopback reader when it's destroyed.
if let Some(id) = self.local_monitor_module.lock().unwrap().take() {
unload_module(id);
}
if let Some(id) = self.sink_module.take() {
unload_module(id);
}
@@ -171,6 +274,18 @@ impl Drop for Routing {
}
}
/// The app-audio state to announce at capture start, if any. Only strict per-app
/// mode warrants one: there the loopback is suppressed, so the viewer hears
/// silence until the chosen app's first stream routes — surface that as an
/// initial `lost`. In every other mode (whole-desktop, or best-effort app
/// filtering) the loopback keeps audio flowing from the outset, so there is no
/// initial gap to report. Pure: no I/O, so the emit decision is unit-testable.
pub(super) fn initial_app_audio_state(
opts: &HostOpts,
) -> Option<crate::common::output::AppAudioState> {
(opts.app.is_some() && opts.strict_audio).then_some(crate::common::output::AppAudioState::Lost)
}
// ──────────────────────────────────────────────────────────────────────
// App enumeration (interactive picker source)
// ──────────────────────────────────────────────────────────────────────
+64
View File
@@ -488,9 +488,73 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app {
if opts.strict_audio {
bits.push(format!("app-audio={app} (strict)"));
} else {
bits.push(format!("app-audio={app}"));
}
} else {
bits.push("system-audio".to_string());
}
bits.join(" + ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Quality;
fn opts(app: Option<&str>, strict_audio: bool) -> HostOpts {
HostOpts {
window: false,
app: app.map(str::to_string),
strict_audio,
display_server: None,
quality: Quality::Auto,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
relay: None,
}
}
#[test]
fn capture_summary_reflects_audio_mode() {
assert_eq!(
capture_summary(&opts(None, false)),
"fullscreen + system-audio"
);
assert_eq!(
capture_summary(&opts(Some("Firefox"), false)),
"fullscreen + app-audio=Firefox"
);
// strict only shows when an app is selected.
assert_eq!(
capture_summary(&opts(Some("Firefox"), true)),
"fullscreen + app-audio=Firefox (strict)"
);
assert_eq!(
capture_summary(&opts(None, true)),
"fullscreen + system-audio"
);
}
#[test]
fn initial_app_audio_is_lost_only_in_strict_app_mode() {
use crate::common::output::AppAudioState;
use crate::host::audio::initial_app_audio_state;
// Strict + app: announce silence up front (loopback suppressed).
assert_eq!(
initial_app_audio_state(&opts(Some("Firefox"), true)),
Some(AppAudioState::Lost)
);
// Best-effort app (no strict): loopback covers the gap → no initial event.
assert_eq!(initial_app_audio_state(&opts(Some("Firefox"), false)), None);
// Whole-desktop (strict is ignored without --app): no per-app events.
assert_eq!(initial_app_audio_state(&opts(None, true)), None);
assert_eq!(initial_app_audio_state(&opts(None, false)), None);
}
}
+1
View File
@@ -205,6 +205,7 @@ mod tests {
HostOpts {
window: false,
app: None,
strict_audio: false,
display_server: None::<DisplayServerArg>,
quality,
bitrate: None,
+9 -7
View File
@@ -12,8 +12,7 @@ use ashpd::{
},
};
use nix::fcntl::{FcntlArg, FdFlag, fcntl};
use nix::unistd::close;
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality;
@@ -61,11 +60,14 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
let pw_fd: OwnedFd = proxy.open_pipe_wire_remote(&session).await?;
tracing::info!(node_id, width = w, height = h, "portal handshake complete");
// The fd is CLOEXEC by default; the gst child needs to inherit it across
// exec. We then leak it via into_raw_fd so its lifetime spans the spawn,
// and close the parent's copy once gst is running (the pipeline's
// after_spawn hook below).
// exec, so clear CLOEXEC. We keep the OwnedFd alive across the spawn (gst
// inherits its own copy at exec) by moving it into the after_spawn hook,
// which drops — and so closes — the parent's copy once gst is running. If
// pipeline::spawn errors *before* calling the hook (e.g. audio setup or the
// gst spawn fails), the unused closure is dropped, dropping the fd just the
// same — so the portal fd never leaks on the error path.
clear_cloexec(&pw_fd)?;
let raw_fd: RawFd = pw_fd.into_raw_fd();
let raw_fd: RawFd = pw_fd.as_raw_fd();
let source_args = vec![
"pipewiresrc".to_string(),
@@ -81,7 +83,7 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
source_args,
move || {
// Parent no longer needs the pipewire fd — gst inherited its own copy.
let _ = close(raw_fd);
drop(pw_fd);
},
)
.await
+14 -4
View File
@@ -37,12 +37,22 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
}
};
// XDamage capture (`use-damage=true`) only re-grabs changed screen
// regions instead of copying the whole root window every frame. On a busy
// desktop that is the difference between a usable framerate and ~1 fps —
// `use-damage=false` does a full XGetImage per frame, which collapses on
// servers without working MIT-SHM (and pins the CPU everywhere else).
// Kept as the default; `PIXELPASS_X11_NO_DAMAGE=1` restores full-frame
// capture if a driver produces partial-update artifacts with damage on.
let use_damage = if std::env::var_os("PIXELPASS_X11_NO_DAMAGE").is_some() {
"use-damage=false"
} else {
"use-damage=true"
};
let mut source_args = vec![
"ximagesrc".to_string(),
// Full frames (no damage regions) to avoid partial-update artifacts;
// use-damage=true is a later CPU optimization. show-pointer matches
// Wayland's CursorMode::Embedded.
"use-damage=false".to_string(),
// show-pointer matches Wayland's CursorMode::Embedded.
use_damage.to_string(),
"show-pointer=true".to_string(),
];
if let Some(xid) = xid {
+5 -2
View File
@@ -279,9 +279,12 @@ impl Player {
Player::Mpv => crate::common::process::spawn_detached(
"mpv",
&[
// No `--untimed`: it ignores audio timestamps and drifts a
// shared video out of sync. Pacing to audio keeps A/V synced.
// Also leave hwdec at the `low-latency` default (software
// decode): forcing `--hwdec=auto` froze some viewers on
// frame 1 while audio kept playing.
"--profile=low-latency",
"--untimed",
"--hwdec=auto",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
+8
View File
@@ -1,5 +1,6 @@
mod cli;
mod common;
mod doctor;
#[cfg(feature = "gui")]
mod gui;
mod host;
@@ -36,6 +37,13 @@ async fn main() -> Result<()> {
}
}
// Diagnostics run before pipewire::init() (they don't need it) and work
// regardless of the `gui` feature, so a headless tester can probe their box.
if cli.doctor {
let relay = common::endpoint::relay_override(cli.relay.as_deref());
return doctor::run(relay).await;
}
// libpipewire requires global init before any pw_* call. Idempotent;
// safe to call even when the per-app audio thread never spawns.
pipewire::init();
+46 -7
View File
@@ -50,13 +50,11 @@ pub async fn run() -> Result<()> {
if m.name != "module-loopback" {
continue;
}
let Some(sink) = extract_kv(&m.args, "sink") else {
continue;
};
let Some(pid_str) = sink.strip_prefix(SINK_NAME_PREFIX) else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
// A pixelpass loopback references a capture sink either as its
// destination (`sink=pixelpass_capture_<pid>` — the default→null
// mirror) or as its source (`source=pixelpass_capture_<pid>.monitor`
// — the local monitor that lets the sharer hear the app). Match both.
let Some(pid) = loopback_capture_pid(&m.args) else {
continue;
};
if dead_pids.contains(&pid) {
@@ -166,6 +164,19 @@ fn list_modules() -> Result<Vec<Module>> {
Ok(modules)
}
/// The `pixelpass_capture_<pid>` PID a loopback references, whether the capture
/// sink is its destination (`sink=pixelpass_capture_<pid>`) or its source
/// (`source=pixelpass_capture_<pid>.monitor`). `None` for unrelated loopbacks.
fn loopback_capture_pid(args: &str) -> Option<u32> {
let from_sink = extract_kv(args, "sink").and_then(|v| v.strip_prefix(SINK_NAME_PREFIX));
let from_source = extract_kv(args, "source")
.and_then(|v| v.strip_prefix(SINK_NAME_PREFIX))
.and_then(|rest| rest.strip_suffix(".monitor"));
from_sink
.or(from_source)
.and_then(|pid| pid.parse::<u32>().ok())
}
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
for token in args.split_whitespace() {
if let Some(rest) = token.strip_prefix(key)
@@ -195,3 +206,31 @@ fn unload_module(id: u32) -> Result<()> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_pid_matches_default_null_mirror_by_sink() {
// The default→null loopback: capture sink is the destination.
let args = "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20";
assert_eq!(loopback_capture_pid(args), Some(4242));
}
#[test]
fn loopback_pid_matches_local_monitor_by_source() {
// The local monitor: capture sink's monitor is the source, and the
// destination is the real default sink (not a pixelpass name).
let args = "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20";
assert_eq!(loopback_capture_pid(args), Some(4242));
}
#[test]
fn loopback_pid_ignores_unrelated_loopback() {
assert_eq!(
loopback_capture_pid("source=alsa_output.pci.monitor sink=some_other_sink"),
None
);
}
}
+13 -2
View File
@@ -71,7 +71,18 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
accepted = listener.accept() => {
let (tcp, peer) = accepted?;
tracing::info!(%peer, "local viewer connected");
crate::common::tunnel::bridge(quic_send, quic_recv, tcp).await
// Race the bridge against ctrl-c so a disconnect lands promptly
// mid-stream (mirrors the host's handle_peer). Without this, the
// cancel token is set but nothing checks it once the player has
// connected — ctrl-c is ignored until a second press, and a GUI
// "Disconnect" only takes effect via the child's SIGKILL backstop.
tokio::select! {
res = crate::common::tunnel::bridge(quic_send, quic_recv, tcp) => res,
_ = cancel.cancelled() => {
tracing::info!("ctrl-c received during stream — disconnecting");
Ok(())
}
}
}
_ = cancel.cancelled() => {
tracing::info!("ctrl-c received before local viewer connected");
@@ -91,7 +102,7 @@ fn print_viewer_banner(url: &str) {
eprintln!("│ Connected to host. Open the stream in your player:");
eprintln!("");
eprintln!(
"│ mpv --profile=low-latency --untimed --hwdec=auto --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
"│ mpv --profile=low-latency --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
);
eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}");
eprintln!("");