diff --git a/README.md b/README.md index a613844..671eeb9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Working: - Audio capture of the default sink's monitor, with optional per-app routing (`--app `) - `--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 `) @@ -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 diff --git a/src/cli.rs b/src/cli.rs index 34280d5..7409189 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -105,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. diff --git a/src/common/deps.rs b/src/common/deps.rs index b82ddb4..dc770ef 100644 --- a/src/common/deps.rs +++ b/src/common/deps.rs @@ -56,12 +56,7 @@ fn require(bin: &str) -> Result { } 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 { +/// 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 { 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 { 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, pkg: &str) -> String { format!("Install hint: {cmd}") } -fn detect_distro() -> Option { +pub(crate) fn detect_distro() -> Option { let contents = std::fs::read_to_string("/etc/os-release").ok()?; for line in contents.lines() { if let Some(rest) = line.strip_prefix("ID=") { diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..984c81d --- /dev/null +++ b/src/doctor.rs @@ -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, +} + +impl Check { + fn new(status: Status, label: impl Into, detail: impl Into) -> Self { + Self { + status, + label: label.into(), + detail: detail.into(), + hint: None, + } + } + + fn ok(label: impl Into, detail: impl Into) -> Self { + Self::new(Status::Ok, label, detail) + } + fn warn(label: impl Into, detail: impl Into) -> Self { + Self::new(Status::Warn, label, detail) + } + fn fail(label: impl Into, detail: impl Into) -> Self { + Self::new(Status::Fail, label, detail) + } + fn info(label: impl Into, detail: impl Into) -> Self { + Self::new(Status::Info, label, detail) + } + + fn with_hint(mut self, hint: impl Into) -> 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, +} + +/// 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) -> 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(§ions); + + let summary = summarize(sections.iter().flat_map(|s| s.checks.iter())); + print_summary(summary, §ions); + + 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 { + 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 { + 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 §ion.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) -> 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 { + 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(§ions).contains("capture")); + } +} diff --git a/src/main.rs b/src/main.rs index fb0207b..24ca729 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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();