Consumes the new pixelpass --strict-audio mode + app_audio events (pixelpass
85fdebe) to close Codex's A23 P2: the per-app pick alone was best-effort —
pixelpass would fall back to whole-desktop audio before the app routed and again
if it stopped, both reintroducing the call echo.
- host_args appends --strict-audio alongside --app=<name>, so pixelpass never
mirrors the desktop for our share: viewers hear only the chosen app or silence.
- Parse the app_audio JSON event (routed/lost) into PixelpassEvent; spawn_host
takes an optional notices channel and the stdout drain forwards events on it.
- Core spawns a forwarder (only when an app is selected) mapping routed/lost to
UiEvent::ShareAudioActive(bool); the channel/task self-terminate on host EOF.
- App tracks share_audio_dropped and shows a transient warning under the "Sharing
your screen" badge when the chosen app's audio stops ("viewers hear silence
until it plays again"). Reset on start/stop/room-leave.
In-process mpsc only — no wire/GOSSIP_PROTO change. 435 lib tests (+2: app_audio
parse + the dropped-flag state machine), clippy --all-targets + release clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
770 lines
30 KiB
Rust
770 lines
30 KiB
Rust
//! 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<PixelpassEvent> {
|
|
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=<name> --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=<name>` 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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
let run = Command::new("pactl")
|
|
.args(["-f", "json", "list", "sink-inputs"])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.output();
|
|
match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
|
|
Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// 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<String> {
|
|
let Ok(entries) = serde_json::from_slice::<Vec<SinkInput>>(stdout) else {
|
|
return Vec::new();
|
|
};
|
|
let mut names: Vec<String> = 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<String>,
|
|
}
|
|
|
|
/// 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<String> {
|
|
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<String> {
|
|
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<PathBuf> {
|
|
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=<name>]`), 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<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
|
) -> std::io::Result<(Child, String)> {
|
|
let mut child = Command::new(bin)
|
|
.args(host_args(audio_app))
|
|
.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 host stdout missing"))?;
|
|
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;
|
|
return Err(std::io::Error::other(
|
|
"pixelpass host exited before emitting a ticket",
|
|
));
|
|
}
|
|
Err(e) => {
|
|
let _ = child.kill().await;
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
drain_in_background(lines, "host", notices);
|
|
Ok((child, ticket))
|
|
}
|
|
|
|
/// 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<Child> {
|
|
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<R, T>(
|
|
lines: &mut tokio::io::Lines<BufReader<R>>,
|
|
mut pick: impl FnMut(PixelpassEvent) -> Option<T>,
|
|
) -> std::io::Result<Option<T>>
|
|
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<R>(
|
|
mut lines: tokio::io::Lines<BufReader<R>>,
|
|
role: &'static str,
|
|
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
|
) 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<Child> {
|
|
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=<name>` 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::<String>::new());
|
|
assert_eq!(parse_audio_apps(b"not json"), Vec::<String>::new());
|
|
assert_eq!(parse_audio_apps(b"[]"), Vec::<String>::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 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<PathBuf> = 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")]);
|
|
}
|
|
}
|