//! `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 = [ 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")); } }