//! Screen-share integration: drive `pixelpass` as a child process. //! //! peerspeak owns voice; pixelpass owns pixels. The two are **never** Cargo //! dependencies of each other — the contract is pixelpass's CLI flags plus its //! `--output json` stdout event stream, treated as a stable public API. This //! module spawns a pixelpass *host* (to share our screen) or a *viewer* (to //! watch a peer's share), scrapes the JSON it needs, and otherwise stays out of //! the way. Absence of the `pixelpass` binary is a normal, handled state — the //! UI degrades to a disabled "install pixelpass" control rather than erroring. //! //! The only piece that travels between peers is the host's relay **ticket**: //! peerspeak puts it on the sharer's presence (see `PeerState.sharing`) so the //! room's existing gossip plane distributes it, and each viewer one-clicks it //! into a local pixelpass viewer. The ticket *is* the capability, so this is //! "click to grant the room access" with no ACL to fight. use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; /// The binary we shell out to. Looked up on `$PATH` unless a config override /// points elsewhere. const PIXELPASS_BIN: &str = "pixelpass"; #[cfg(windows)] fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] { [dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")] } #[cfg(not(windows))] fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] { [dir.join(PIXELPASS_BIN)] } /// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format /// growth, but reject unbounded gossip payloads before the UI offers "Watch". const MAX_TICKET_LEN: usize = 512; /// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names /// are short ("Firefox", "mpv"); this only guards against a pathological value. const MAX_APP_NAME_LEN: usize = 256; /// How long to wait for the host to emit its ticket / the viewer to connect /// before giving up and killing the child. Startup is normally sub-second; this /// is only a safety net so a hung pixelpass can't wedge the caller forever. const STARTUP_TIMEOUT: Duration = Duration::from_secs(20); /// One parsed line from pixelpass's `--output json` stdout stream. Mirrors the /// `event` tags in pixelpass's `src/common/output.rs`. Recognized-but-unused /// events collapse to [`PixelpassEvent::Other`]; blank or non-JSON lines parse /// to `None`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PixelpassEvent { /// Host: the relay ticket a viewer needs. Emitted once at host startup. Ticket(String), /// Viewer: the local player URL is ready to open. Connected(String), /// Host: a viewer joined; carries the new active count and the cap. ViewerJoined { active: u32, max: u32 }, /// Host: a viewer left. ViewerLeft { active: u32, max: u32 }, /// Host: a viewer was turned away (host full or capture spawn failed). Refused(String), /// Host: capture pipeline spawned (on first viewer). CaptureStarted, /// Host: capture pipeline torn down (on last viewer). CaptureStopped, /// Host (per-app audio): the chosen app's audio is now reaching viewers. AppAudioRouted, /// Host (per-app audio): the chosen app's last audio stream went away. Under /// our `--strict-audio` run this means viewers now hear silence (not the call /// echo) until the app produces audio again — we surface it as a warning. AppAudioLost, /// A recognized event we don't act on (e.g. `host_info`). Other, } /// Parse a single stdout line from pixelpass `--output json`. Pure: no I/O. pub fn parse_pixelpass_event(line: &str) -> Option { let line = line.trim(); if line.is_empty() { return None; } let v: serde_json::Value = serde_json::from_str(line).ok()?; let event = v.get("event")?.as_str()?; let ev = match event { "ticket" => PixelpassEvent::Ticket(v.get("value")?.as_str()?.to_string()), "connected" => PixelpassEvent::Connected(v.get("url")?.as_str()?.to_string()), "viewer_joined" => PixelpassEvent::ViewerJoined { active: json_u32(&v, "active"), max: json_u32(&v, "max"), }, "viewer_left" => PixelpassEvent::ViewerLeft { active: json_u32(&v, "active"), max: json_u32(&v, "max"), }, "viewer_refused" => PixelpassEvent::Refused( v.get("reason") .and_then(|r| r.as_str()) .unwrap_or("") .to_string(), ), "capture" => match v.get("state").and_then(|s| s.as_str()) { Some("started") => PixelpassEvent::CaptureStarted, Some("stopped") => PixelpassEvent::CaptureStopped, _ => PixelpassEvent::Other, }, "app_audio" => match v.get("state").and_then(|s| s.as_str()) { Some("routed") => PixelpassEvent::AppAudioRouted, Some("lost") => PixelpassEvent::AppAudioLost, _ => PixelpassEvent::Other, }, _ => PixelpassEvent::Other, }; Some(ev) } fn json_u32(v: &serde_json::Value, key: &str) -> u32 { v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32 } /// Build the argv for a pixelpass *host*. Always `--host --output json`; when /// `audio_app` is `Some`, append `--app= --strict-audio` so pixelpass /// captures only that app's audio instead of the whole desktop sink monitor /// (which contains our own call playout → the viewer would hear themselves /// echoed back, backlog A23). /// /// `--strict-audio` is what makes the fix a guarantee rather than best-effort: /// without it, pixelpass falls back to the whole-desktop loopback before the /// app's first stream routes and again if the app's audio later stops — both of /// which reintroduce the echo. With it, the viewer hears only the chosen app (or /// silence), and pixelpass emits `app_audio` events we surface as a warning. /// /// The name is passed in the single-token `--app=` form so a value that /// happens to begin with `-` can never be reparsed as a pixelpass flag (clap /// otherwise rejects hyphen-leading option values). The name is locally chosen /// (our own enumeration / the user's pick), not peer-supplied, but is still /// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O. pub fn host_args(audio_app: Option<&str>) -> Vec { let mut args = vec![ "--host".to_string(), "--output".to_string(), "json".to_string(), ]; if let Some(name) = audio_app.and_then(sanitize_app_name) { args.push(format!("--app={name}")); args.push("--strict-audio".to_string()); } args } /// Validate a locally-chosen audio app name before it becomes a `--app` value: /// trim, reject empty / overlong, and reject names carrying control characters /// (newlines etc.) that have no place in a real `application.name`. `None` means /// "no valid app selected" — the caller then shares the whole desktop audio. pub fn sanitize_app_name(name: &str) -> Option { let name = name.trim(); let ok = !name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control()); ok.then(|| name.to_string()) } /// Hard cap on how long enumeration waits for `pactl`. It runs inline on the core /// command loop (the picker awaits it before opening), so a wedged/slow `pactl` /// must not stall mute/deafen/leave/stop. On timeout we treat it like any other /// failure: empty list → "All system audio" only. const LIST_APPS_TIMEOUT: Duration = Duration::from_secs(2); /// Enumerate the apps currently sending audio to a sink, deduplicated by /// `application.name`. Mirrors how pixelpass itself builds its interactive /// picker (`pactl -f json list sink-inputs`), so the names we return are exactly /// the ones `--app` matches against. Returns an empty list on any error (pactl /// missing, non-PipeWire host, nothing playing, or [`LIST_APPS_TIMEOUT`] elapsed) /// — a normal, handled state that leaves the picker showing only "All system /// audio". pub async fn list_audio_apps() -> Vec { let run = Command::new("pactl") .args(["-f", "json", "list", "sink-inputs"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) // On [`LIST_APPS_TIMEOUT`] the `output()` future is dropped, which drops // the child — `kill_on_drop(true)` then SIGKILLs and reaps it so a wedged // `pactl` can't linger/accumulate across picker opens (audit P3). .kill_on_drop(true) .output(); match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await { Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout), _ => Vec::new(), } } /// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or /// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only /// picker (safe), never a stalled core loop. const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2); /// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass /// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app /// share always appends `--strict-audio`, and an **older** pixelpass would have /// clap reject the unknown flag → the host spawn hard-fails and the share is /// broken (audit P2, version skew). When unsupported the picker degrades to /// whole-desktop audio only — we never silently drop to best-effort `--app`, which /// would reintroduce the call echo (A23). /// /// Any probe failure/timeout returns `false` (degrade to the safe path). The /// `--help` child is `kill_on_drop` so a hung pixelpass can't linger. pub async fn supports_strict_audio(bin: &Path) -> bool { let run = Command::new(bin) .arg("--help") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true) .output(); match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await { Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout), _ => false, } } /// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the /// flag token rather than a whole line, since clap may wrap/realign help text. pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool { String::from_utf8_lossy(help_stdout).contains("--strict-audio") } /// Parse `pactl -f json list sink-inputs` stdout into a sorted, deduplicated list /// of `application.name`s. Pure: no I/O. Unparseable input yields an empty list. /// Each name is passed through [`sanitize_app_name`] so the picker only ever /// offers names that will actually survive [`host_args`]; otherwise a name that /// parses here but fails sanitization later would be selectable yet silently /// drop the `--app` flag and revert the share to whole-desktop audio (A23 echo). pub fn parse_audio_apps(stdout: &[u8]) -> Vec { let Ok(entries) = serde_json::from_slice::>(stdout) else { return Vec::new(); }; let mut names: Vec = entries .into_iter() .filter_map(|e| e.properties.application_name) .filter_map(|n| sanitize_app_name(&n)) .collect(); names.sort_unstable(); names.dedup(); names } #[derive(serde::Deserialize)] struct SinkInput { properties: SinkInputProperties, } #[derive(serde::Deserialize)] struct SinkInputProperties { #[serde(rename = "application.name")] application_name: Option, } /// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it /// rides gossip presence, which is untrusted and spoofable), so flags come first /// and the ticket is passed as a positional **after a `--` end-of-options /// guard**. That guard means a ticket beginning with `-`/`--` can never be /// reinterpreted as a pixelpass flag — closing the argument-injection vector /// (security finding S3). Pure: no I/O. pub fn viewer_args(ticket: &str) -> Vec { vec![ "--output".to_string(), "json".to_string(), "--".to_string(), ticket.to_string(), ] } /// Sanitize a peer-advertised pixelpass ticket at the gossip boundary. Peerspeak /// intentionally does not depend on pixelpass/iroh-tickets, so this validates the /// stable CLI ticket envelope we consume: bounded ASCII endpoint tickets beginning /// with `endpoint`. Invalid input becomes `None`, which removes the Watch button. pub fn sanitize_ticket(ticket: String) -> Option { let ticket = ticket.trim(); let valid_len = !ticket.is_empty() && ticket.len() <= MAX_TICKET_LEN; let valid_shape = ticket.starts_with("endpoint") && ticket.len() > "endpoint".len() && ticket.bytes().all(|b| b.is_ascii_alphanumeric()); (valid_len && valid_shape).then(|| ticket.to_string()) } /// Resolve the pixelpass binary: an explicit config override (used only if it /// points at an existing file), otherwise the first `pixelpass` found on /// `$PATH`. `None` means it isn't installed — a normal, handled state. An /// override that doesn't resolve falls through to the `$PATH` search rather than /// failing outright. pub fn pixelpass_path(config_override: Option<&str>) -> Option { if let Some(p) = config_override { let p = p.trim(); if !p.is_empty() { let pb = PathBuf::from(p); if pb.is_file() { return Some(pb); } // Override set but missing — fall through to the $PATH search. } } let path_var = std::env::var_os("PATH")?; std::env::split_paths(&path_var) .flat_map(|dir| pixelpass_path_candidates(&dir)) .find(|c| c.is_file()) } /// Whether pixelpass is available to shell out to. pub fn is_available(config_override: Option<&str>) -> bool { pixelpass_path(config_override).is_some() } /// Spawn a pixelpass host (`pixelpass --host --output json [--app=]`), wait /// for its startup ticket, and return the live child plus the ticket. When /// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the /// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps /// running (streaming to viewers) until killed or dropped; remaining stdout is /// drained in a background task so a full pipe can't stall the host. We do /// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap, /// protecting the sharer's uplink, and refuses extras with `viewer_refused`. pub async fn spawn_host( bin: &Path, audio_app: Option<&str>, notices: Option>, ) -> std::io::Result<(Child, String)> { let mut child = Command::new(bin) .args(host_args(audio_app)) .stdin(Stdio::null()) .stdout(Stdio::piped()) // Capture stderr (not null): pixelpass prints its startup precondition // failures there — a missing GStreamer plugin / `pactl`, each with an // actionable "Install hint: sudo apt install ..." line. If the host dies // before its ticket we fold that tail into our error so the user sees // *what to install* instead of a dead-end "exited before a ticket". On // the success path we drain it in the background so the pipe can't fill. .stderr(Stdio::piped()) .kill_on_drop(true) .spawn()?; let stdout = child .stdout .take() .ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?; let stderr = child.stderr.take(); let mut lines = BufReader::new(stdout).lines(); let ticket = match read_until(&mut lines, |e| match e { PixelpassEvent::Ticket(t) => Some(t), _ => None, }) .await { Ok(Some(t)) => t, Ok(None) => { let _ = child.kill().await; let detail = read_stderr_tail(stderr).await; return Err(std::io::Error::other(format!( "pixelpass host exited before emitting a ticket{detail}" ))); } Err(e) => { let _ = child.kill().await; return Err(e); } }; if let Some(stderr) = stderr { drain_stderr_in_background(stderr); } drain_in_background(lines, "host", notices); Ok((child, ticket)) } /// Read a killed pixelpass child's stderr to EOF and reduce it to a short, /// user-facing diagnostic tail via [`pixelpass_failure_detail`]. Bounded: the /// caller kills the child first, so the pipe EOFs promptly. Returns an empty /// string when stderr was already taken or carried nothing useful. async fn read_stderr_tail(stderr: Option) -> String { use tokio::io::AsyncReadExt; let Some(mut stderr) = stderr else { return String::new(); }; let mut buf = Vec::new(); let _ = stderr.read_to_end(&mut buf).await; pixelpass_failure_detail(&String::from_utf8_lossy(&buf)) } /// Discard a running pixelpass child's stderr in the background so its pipe /// can't fill and stall the host (mirrors [`drain_in_background`] for stdout). fn drain_stderr_in_background(mut stderr: tokio::process::ChildStderr) { use tokio::io::AsyncReadExt; tokio::spawn(async move { let mut buf = [0u8; 4096]; while let Ok(n) = stderr.read(&mut buf).await { if n == 0 { break; } } }); } /// Extract a human-useful tail from a failed pixelpass child's stderr to append /// to our error. pixelpass writes actionable startup errors there (a missing /// GStreamer element / `pactl` plus an `Install hint: sudo apt install ...` /// line), which is exactly what a freshly-installed host needs to see. The /// decorative host banner (box-drawing) is dropped — it only prints on the /// success path, but we filter it defensively. Pure: no I/O. Returns an empty /// string when there's nothing worth surfacing (so callers can append blindly). pub fn pixelpass_failure_detail(stderr: &str) -> String { let useful: Vec<&str> = stderr .lines() .map(str::trim_end) .filter(|l| !l.trim().is_empty()) .filter(|l| !l.trim_start().starts_with(['│', '┌', '└', '├'])) .collect(); if useful.is_empty() { return String::new(); } // The anyhow error and its install hint are the *last* lines printed, so // keep the tail rather than the head. const MAX_LINES: usize = 12; let start = useful.len().saturating_sub(MAX_LINES); format!("\n\npixelpass reported:\n{}", useful[start..].join("\n")) } /// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the /// stream in a local player (mpv, falling back to vlc). Returns the live viewer /// child so the caller can kill it on room-leave; it also self-exits when the /// player window closes (its tunnel ends). pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result { let mut child = Command::new(bin) .args(viewer_args(ticket)) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true) .spawn()?; let stdout = child .stdout .take() .ok_or_else(|| std::io::Error::other("pixelpass viewer stdout missing"))?; let mut lines = BufReader::new(stdout).lines(); let url = match read_until(&mut lines, |e| match e { PixelpassEvent::Connected(u) => Some(u), _ => None, }) .await { Ok(Some(u)) => u, Ok(None) => { let _ = child.kill().await; return Err(std::io::Error::other( "pixelpass viewer exited before connecting", )); } Err(e) => { let _ = child.kill().await; return Err(e); } }; if let Err(e) = launch_player(&url) { let _ = child.kill().await; return Err(e); } drain_in_background(lines, "viewer", None); Ok(child) } /// Read JSON event lines until `pick` returns `Some(value)`. Returns `Ok(None)` /// on EOF (child exited first) and `Err` on an I/O error or the startup timeout. async fn read_until( lines: &mut tokio::io::Lines>, mut pick: impl FnMut(PixelpassEvent) -> Option, ) -> std::io::Result> where R: tokio::io::AsyncRead + Unpin, { loop { match tokio::time::timeout(STARTUP_TIMEOUT, lines.next_line()).await { Ok(Ok(Some(line))) => { if let Some(ev) = parse_pixelpass_event(&line) && let Some(v) = pick(ev) { return Ok(Some(v)); } } Ok(Ok(None)) => return Ok(None), Ok(Err(e)) => return Err(e), Err(_) => { return Err(std::io::Error::new( std::io::ErrorKind::TimedOut, "timed out waiting for pixelpass startup event", )); } } } } /// Keep reading the child's stdout to EOF in the background so a full pipe can't /// stall it; log notable events for diagnostics. When `notices` is `Some`, each /// parsed event is also forwarded to the caller (the core, which translates the /// `app_audio` ones into a UI warning); a send failure (receiver dropped) just /// stops forwarding, draining continues. The task ends on EOF (child exited). fn drain_in_background( mut lines: tokio::io::Lines>, role: &'static str, notices: Option>, ) where R: tokio::io::AsyncRead + Unpin + Send + 'static, { tokio::spawn(async move { while let Ok(Some(line)) = lines.next_line().await { if let Some(ev) = parse_pixelpass_event(&line) { crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev))); if let Some(tx) = ¬ices { let _ = tx.send(ev); } } } }); } fn event_for_log(ev: &PixelpassEvent) -> String { match ev { PixelpassEvent::Ticket(ticket) => format!("ticket {}", crate::redact_for_log(ticket)), PixelpassEvent::Connected(_) => "connected".to_string(), PixelpassEvent::ViewerJoined { active, max } => { format!("viewer_joined active={active} max={max}") } PixelpassEvent::ViewerLeft { active, max } => { format!("viewer_left active={active} max={max}") } PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"), PixelpassEvent::CaptureStarted => "capture_started".to_string(), PixelpassEvent::CaptureStopped => "capture_stopped".to_string(), PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(), PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(), PixelpassEvent::Other => "other".to_string(), } } /// Open the viewer stream URL in a media player. Mirrors pixelpass's own /// low-latency mpv invocation; falls back to vlc. The player is reaped in a /// background task so it doesn't linger as a zombie when its window closes. fn launch_player(url: &str) -> std::io::Result<()> { const MPV_ARGS: &[&str] = &[ "--profile=low-latency", "--untimed", "--hwdec=auto", "--audio-buffer=0.2", "--demuxer-max-bytes=2M", "--demuxer-readahead-secs=0.5", ]; const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"]; let child = match spawn_player("mpv", MPV_ARGS, url) { Ok(c) => c, Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| { std::io::Error::new( std::io::ErrorKind::NotFound, "no media player found — install mpv or vlc to watch screen shares", ) })?, }; tokio::spawn(async move { let mut child = child; let _ = child.wait().await; }); Ok(()) } fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result { Command::new(bin) .args(args) .arg(url) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .kill_on_drop(false) .spawn() } #[cfg(test)] mod tests { use super::*; #[test] fn viewer_args_guard_neutralizes_flag_like_ticket() { // A malicious "ticket" that looks like a flag must end up positional, // i.e. after the `--` end-of-options guard, never parsed as a flag. let args = viewer_args("--malicious-flag"); assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last"); let guard = args .iter() .position(|a| a == "--") .expect("`--` guard present"); let ticket = args.len() - 1; assert!(guard < ticket, "ticket must follow the `--` guard"); // The real flags are parsed before the guard. let out = args.iter().position(|a| a == "--output").unwrap(); assert!(out < guard, "flags precede the `--` guard"); } #[test] fn viewer_args_ordinary_ticket() { assert_eq!( viewer_args("abc123"), vec!["--output", "json", "--", "abc123"] ); } #[test] fn host_args_without_app_shares_whole_desktop() { // No app selected → no --app flag → pixelpass keeps its default // (whole-desktop) audio capture. assert_eq!(host_args(None), vec!["--host", "--output", "json"]); } #[test] fn host_args_with_app_appends_single_token_flag() { // The chosen app rides in the `--app=` single-token form so a // name beginning with `-` can never be reparsed as a flag (A23), plus // `--strict-audio` so pixelpass never falls back to whole-desktop audio. assert_eq!( host_args(Some("Firefox")), vec![ "--host", "--output", "json", "--app=Firefox", "--strict-audio" ] ); // The hyphen-leading name is still bound to --app as a single token; // --strict-audio is the trailing flag. let args = host_args(Some("-rm -rf")); assert_eq!(args[3], "--app=-rm -rf"); assert_eq!(args[4], "--strict-audio"); } #[test] fn host_args_blank_or_control_app_is_dropped() { // An empty / whitespace / control-laden selection is sanitized away, // falling back to whole-desktop capture rather than a broken flag. assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]); assert_eq!( host_args(Some("bad\nname")), vec!["--host", "--output", "json"] ); } #[test] fn sanitize_app_name_trims_and_rejects_garbage() { assert_eq!( sanitize_app_name(" Firefox \n"), Some("Firefox".to_string()) ); assert_eq!(sanitize_app_name(""), None); assert_eq!(sanitize_app_name(" "), None); assert_eq!(sanitize_app_name("a\tb"), None); assert_eq!(sanitize_app_name(&"x".repeat(MAX_APP_NAME_LEN + 1)), None); } #[test] fn parse_audio_apps_dedups_and_sorts_by_application_name() { let stdout = br#"[ {"index":1,"properties":{"application.name":"Firefox"}}, {"index":2,"properties":{"application.name":"mpv"}}, {"index":3,"properties":{"application.name":"Firefox"}}, {"index":4,"properties":{"application.name":" Spotify "}}, {"index":5,"properties":{"application.name":""}}, {"index":6,"properties":{"other":"no name here"}} ]"#; assert_eq!( parse_audio_apps(stdout), vec![ "Firefox".to_string(), "Spotify".to_string(), "mpv".to_string() ] ); } #[test] fn parse_audio_apps_empty_or_garbage_is_empty() { assert_eq!(parse_audio_apps(b""), Vec::::new()); assert_eq!(parse_audio_apps(b"not json"), Vec::::new()); assert_eq!(parse_audio_apps(b"[]"), Vec::::new()); } #[test] fn parse_audio_apps_drops_names_host_args_would_reject() { // Names that parse from pactl but fail `sanitize_app_name` (control chars, // overlong) must NOT be offered in the picker — otherwise the user could // pick one, `host_args` would silently drop `--app`, and the share would // revert to whole-desktop audio (A23 echo) with no signal. The valid name // survives; the control-char and overlong ones are filtered out. let overlong = "x".repeat(MAX_APP_NAME_LEN + 1); let stdout = format!( r#"[ {{"index":1,"properties":{{"application.name":"mpv"}}}}, {{"index":2,"properties":{{"application.name":"bad\nname"}}}}, {{"index":3,"properties":{{"application.name":"{overlong}"}}}} ]"# ); assert_eq!(parse_audio_apps(stdout.as_bytes()), vec!["mpv".to_string()]); } #[test] fn failure_detail_surfaces_install_hint_and_drops_banner() { // The real shape of a fresh-host failure: anyhow error + install hint on // stderr. We must keep those (so the user knows what to apt install) and // drop the decorative banner box-drawing lines. let stderr = "\ ┌─ PixelPass · host ───────────────────────────────────────── │ display server : Wayland └──────────────────────────────────────────────────────────── Error: GStreamer element `vah264enc` not available. Install hint: sudo apt install gstreamer1.0-plugins-bad "; let detail = pixelpass_failure_detail(stderr); assert!(detail.starts_with("\n\npixelpass reported:\n")); assert!(detail.contains("vah264enc` not available")); assert!(detail.contains("sudo apt install gstreamer1.0-plugins-bad")); assert!(!detail.contains('│'), "banner box-drawing must be dropped"); assert!(!detail.contains('┌')); } #[test] fn failure_detail_empty_when_nothing_useful() { // Blank / banner-only stderr yields an empty string so the caller can // append it to the base message unconditionally without trailing noise. assert_eq!(pixelpass_failure_detail(""), ""); assert_eq!(pixelpass_failure_detail(" \n \n"), ""); assert_eq!( pixelpass_failure_detail("│ display server : Wayland\n│ capture : x\n"), "" ); } #[test] fn failure_detail_keeps_only_the_tail() { // A long stderr is truncated to its last lines (where the real error // and hint live), not its head. let body: String = (0..30).map(|i| format!("line {i}\n")).collect(); let detail = pixelpass_failure_detail(&body); assert!(detail.contains("line 29")); assert!(!detail.contains("line 0\n")); } #[test] fn help_probe_detects_strict_audio_flag() { // A new pixelpass advertises the flag; an old one doesn't. The probe must // match the token even when clap wraps the option onto its own line. let new_help = b"Options:\n --app \n --strict-audio\n With --app, never fall back..."; assert!(help_mentions_strict_audio(new_help)); let old_help = b"Options:\n --app \n --output \n -h, --help"; assert!(!help_mentions_strict_audio(old_help)); // Garbage / empty output degrades to "unsupported" (safe path). assert!(!help_mentions_strict_audio(b"")); assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00])); } #[test] fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() { let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm"; assert_eq!( sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()) ); } #[test] fn sanitize_ticket_rejects_oversized_or_garbage_ticket() { assert_eq!(sanitize_ticket("not-a-ticket".into()), None); assert_eq!( sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None ); assert_eq!(sanitize_ticket("endpointabc-def".into()), None); } #[test] fn event_log_redacts_ticket_values() { let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm".to_string(); let log = event_for_log(&PixelpassEvent::Ticket(ticket.clone())); assert!(log.contains("endpoint")); assert!(!log.contains(&ticket["endpoint".len() + 8..])); } #[test] fn parses_ticket() { assert_eq!( parse_pixelpass_event(r#"{"event":"ticket","value":"abc123"}"#), Some(PixelpassEvent::Ticket("abc123".to_string())) ); } #[test] fn parses_connected_url() { assert_eq!( parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#), Some(PixelpassEvent::Connected( "http://127.0.0.1:5500".to_string() )) ); } #[test] fn parses_viewer_joined_and_left() { assert_eq!( parse_pixelpass_event(r#"{"event":"viewer_joined","id":"x","active":2,"max":4}"#), Some(PixelpassEvent::ViewerJoined { active: 2, max: 4 }) ); assert_eq!( parse_pixelpass_event(r#"{"event":"viewer_left","id":"x","active":1,"max":4}"#), Some(PixelpassEvent::ViewerLeft { active: 1, max: 4 }) ); } #[test] fn viewer_counts_default_to_zero_when_absent() { assert_eq!( parse_pixelpass_event(r#"{"event":"viewer_joined"}"#), Some(PixelpassEvent::ViewerJoined { active: 0, max: 0 }) ); } #[test] fn parses_refused_with_and_without_reason() { assert_eq!( parse_pixelpass_event(r#"{"event":"viewer_refused","reason":"host is full"}"#), Some(PixelpassEvent::Refused("host is full".to_string())) ); assert_eq!( parse_pixelpass_event(r#"{"event":"viewer_refused"}"#), Some(PixelpassEvent::Refused("".to_string())) ); } #[test] fn parses_capture_states() { assert_eq!( parse_pixelpass_event(r#"{"event":"capture","state":"started"}"#), Some(PixelpassEvent::CaptureStarted) ); assert_eq!( parse_pixelpass_event(r#"{"event":"capture","state":"stopped"}"#), Some(PixelpassEvent::CaptureStopped) ); // Unknown capture state is recognized-but-unused, not a parse failure. assert_eq!( parse_pixelpass_event(r#"{"event":"capture","state":"paused"}"#), Some(PixelpassEvent::Other) ); } #[test] fn parses_app_audio_states() { // The wire contract from pixelpass's --strict-audio run (A23): routed = // the chosen app's audio is live; lost = it stopped (viewers now silent). assert_eq!( parse_pixelpass_event(r#"{"event":"app_audio","state":"routed"}"#), Some(PixelpassEvent::AppAudioRouted) ); assert_eq!( parse_pixelpass_event(r#"{"event":"app_audio","state":"lost"}"#), Some(PixelpassEvent::AppAudioLost) ); // Unknown / missing state is recognized-but-unused, not a parse failure. assert_eq!( parse_pixelpass_event(r#"{"event":"app_audio","state":"weird"}"#), Some(PixelpassEvent::Other) ); assert_eq!( parse_pixelpass_event(r#"{"event":"app_audio"}"#), Some(PixelpassEvent::Other) ); } #[test] fn recognized_but_unused_event_is_other() { assert_eq!( parse_pixelpass_event( r#"{"event":"host_info","display_server":"wayland","max_viewers":4}"# ), Some(PixelpassEvent::Other) ); assert_eq!( parse_pixelpass_event(r#"{"event":"some_future_event"}"#), Some(PixelpassEvent::Other) ); } #[test] fn blank_and_non_json_lines_are_none() { assert_eq!(parse_pixelpass_event(""), None); assert_eq!(parse_pixelpass_event(" "), None); assert_eq!(parse_pixelpass_event("not json at all"), None); // Valid JSON but not an event object. assert_eq!(parse_pixelpass_event("[1,2,3]"), None); assert_eq!(parse_pixelpass_event(r#"{"no_event":"here"}"#), None); } #[test] fn ticket_without_value_is_none() { // A malformed ticket event (missing `value`) must not panic. assert_eq!(parse_pixelpass_event(r#"{"event":"ticket"}"#), None); } #[test] fn surrounding_whitespace_is_tolerated() { assert_eq!( parse_pixelpass_event(" {\"event\":\"ticket\",\"value\":\"t\"}\n"), Some(PixelpassEvent::Ticket("t".to_string())) ); } #[test] fn path_override_to_existing_file_is_used() { // A real file (this source file) stands in for a custom binary location. let this_file = file!(); let resolved = pixelpass_path(Some(this_file)); assert_eq!(resolved.as_deref(), Some(Path::new(this_file))); assert!(is_available(Some(this_file))); } #[test] fn empty_override_falls_through() { // An empty/whitespace override is ignored (falls through to $PATH); we // only assert it doesn't return the empty path as a match. assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new(""))); } #[test] fn pixelpass_path_candidates_are_platform_specific() { let dir = Path::new("bin"); let candidates: Vec = pixelpass_path_candidates(dir).into_iter().collect(); #[cfg(windows)] assert_eq!( candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")] ); #[cfg(not(windows))] assert_eq!(candidates, vec![dir.join("pixelpass")]); } }