feat(host): build desktop audio exclusion foundation

This commit is contained in:
2026-08-21 15:39:07 -04:00
parent 5d3da8b006
commit 781defcd84
16 changed files with 2586 additions and 564 deletions
+326
View File
@@ -0,0 +1,326 @@
//! Typed selection and ownership for the audio branch of a capture.
//!
//! The enum is deliberately shaped so the desktop-excluding mode cannot carry
//! either unsafe legacy input:
//!
//! - only [`CapturePlan::LegacyDesktop`] can contain [`DefaultMonitor`];
//! - only [`CapturePlan::PerApp`] can contain [`Routing`], whose legacy mode may
//! load the default-monitor Pulse loopback;
//! - [`CapturePlan::DesktopExcluding`] contains only [`BareCaptureSink`], which
//! has no module ledger or loopback constructor.
//!
//! Phase 6 will add retained native PipeWire links to the last variant. Until
//! then it intentionally captures silence through a real, connection-owned
//! sink reached by the hidden phase-0d CLI trigger.
use anyhow::{Context, Result, bail};
use tokio::process::Command;
use super::audio::Routing;
use super::graph::BareCaptureSink;
use super::health;
use crate::cli::{CaptureMode, HostOpts};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum CapturePlanKind {
LegacyDesktop,
/// Also covers the legacy `PIXELPASS_AUDIO_VIA_NULL_SINK` dogfood path
/// when no app is selected. The odd name is retained from the design of
/// record so the three safety variants stay mechanically recognisable.
PerApp,
DesktopExcluding,
}
impl CapturePlanKind {
pub(super) fn resolve(opts: &HostOpts) -> Result<Self> {
if opts.capture_mode == CaptureMode::DesktopExcluding {
if opts.app.is_some() {
bail!(
"desktop-excluding capture cannot be combined with --app; legacy Routing is forbidden in this mode"
);
}
if opts.legacy_null_sink {
bail!(
"desktop-excluding capture cannot be combined with PIXELPASS_AUDIO_VIA_NULL_SINK; the default-monitor loopback is forbidden in this mode"
);
}
return Ok(Self::DesktopExcluding);
}
if opts.app.is_some() || opts.legacy_null_sink {
Ok(Self::PerApp)
} else {
Ok(Self::LegacyDesktop)
}
}
}
/// The real default sink's monitor. Construction is private to this module and
/// the value can only inhabit `LegacyDesktop`.
pub(super) struct DefaultMonitor(String);
impl DefaultMonitor {
async fn resolve() -> Result<Self> {
let output = Command::new("pactl")
.arg("get-default-sink")
.output()
.await
.context(
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
)?;
if !output.status.success() {
bail!(
"pactl get-default-sink failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let sink = String::from_utf8(output.stdout)
.context("default sink name was not UTF-8")?
.trim()
.to_string();
if sink.is_empty() {
bail!("pactl get-default-sink returned no name (is a sound server running?)");
}
Ok(Self(format!("{sink}.monitor")))
}
fn gst_device_arg(&self) -> String {
format!("device={}", self.0)
}
}
/// Owns both the typed source choice and every graph object needed to keep that
/// choice alive for the capture lifetime.
pub(super) enum CapturePlan {
LegacyDesktop { source: DefaultMonitor },
PerApp { routing: Routing },
DesktopExcluding { capture_sink: BareCaptureSink },
}
impl CapturePlan {
pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> {
match CapturePlanKind::resolve(opts)? {
CapturePlanKind::LegacyDesktop => Ok(Self::LegacyDesktop {
source: DefaultMonitor::resolve().await?,
}),
CapturePlanKind::PerApp => Ok(Self::PerApp {
routing: Routing::start(opts, health)
.await
.context("audio routing setup failed")?,
}),
CapturePlanKind::DesktopExcluding => Ok(Self::DesktopExcluding {
capture_sink: BareCaptureSink::start(health)
.await
.context("desktop-excluding capture-sink setup failed")?,
}),
}
}
/// The only conversion to GStreamer's stringly `pulsesrc device=...`
/// boundary. Callers cannot supply a free-form source string.
pub(super) fn gst_device_arg(&self) -> String {
match self {
Self::LegacyDesktop { source } => source.gst_device_arg(),
Self::PerApp { routing } => format!("device={}.monitor", routing.sink_name()),
Self::DesktopExcluding { capture_sink } => {
format!("device={}", capture_sink.monitor_name())
}
}
}
#[cfg(test)]
pub(super) fn legacy_fixture(monitor_name: &str) -> Self {
Self::LegacyDesktop {
source: DefaultMonitor(monitor_name.to_string()),
}
}
pub(super) async fn shutdown(self) {
match self {
Self::LegacyDesktop { .. } => {}
Self::PerApp { routing } => routing.shutdown().await,
Self::DesktopExcluding { capture_sink } => capture_sink.shutdown().await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Quality;
use std::time::{Duration, Instant};
fn opts(
app: bool,
strict_audio: bool,
capture_mode: CaptureMode,
legacy_null_sink: bool,
) -> HostOpts {
HostOpts {
window: false,
app: app.then(|| "Firefox".to_string()),
strict_audio,
display_server: None,
quality: Quality::Auto,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
capture_mode,
legacy_null_sink,
relay: None,
}
}
#[test]
fn complete_phase_0d_mode_matrix() {
let mut rows = 0;
for app in [false, true] {
for strict_audio in [false, true] {
for capture_mode in [CaptureMode::Legacy, CaptureMode::DesktopExcluding] {
for legacy_null_sink in [false, true] {
rows += 1;
let opts = opts(app, strict_audio, capture_mode, legacy_null_sink);
let actual = CapturePlanKind::resolve(&opts);
let conflicts = capture_mode == CaptureMode::DesktopExcluding
&& (app || legacy_null_sink);
if conflicts {
assert!(
actual.is_err(),
"conflicting row unexpectedly resolved: {opts:?}"
);
continue;
}
let expected = if capture_mode == CaptureMode::DesktopExcluding {
CapturePlanKind::DesktopExcluding
} else if app || legacy_null_sink {
CapturePlanKind::PerApp
} else {
CapturePlanKind::LegacyDesktop
};
assert_eq!(actual.unwrap(), expected, "wrong plan for {opts:?}");
}
}
}
}
assert_eq!(rows, 16, "the full 2×2×2×2 mode matrix must run");
}
#[test]
fn default_monitor_is_confined_to_the_legacy_variant() {
let plan = CapturePlan::LegacyDesktop {
source: DefaultMonitor("alsa_output.fixture.monitor".to_string()),
};
assert_eq!(plan.gst_device_arg(), "device=alsa_output.fixture.monitor");
assert!(matches!(plan, CapturePlan::LegacyDesktop { .. }));
}
fn pulse_source_exists(name: &str) -> bool {
std::process::Command::new("pactl")
.args(["get-source-volume", name])
.output()
.is_ok_and(|output| output.status.success())
}
fn incoming_links(sink_name: &str) -> Result<Vec<serde_json::Value>> {
let output = std::process::Command::new("pw-dump")
.output()
.context("run pw-dump for the phase-0d graph assertion")?;
if !output.status.success() {
bail!(
"pw-dump failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let objects: serde_json::Value =
serde_json::from_slice(&output.stdout).context("parse pw-dump JSON")?;
let objects = objects
.as_array()
.context("pw-dump root was not an array")?;
let sink_id = objects
.iter()
.find(|object| {
object.get("type").and_then(serde_json::Value::as_str)
== Some("PipeWire:Interface:Node")
&& object
.pointer("/info/props/node.name")
.and_then(serde_json::Value::as_str)
== Some(sink_name)
})
.and_then(|object| object.get("id"))
.and_then(serde_json::Value::as_u64)
.context("bare capture sink was absent from pw-dump")?;
Ok(objects
.iter()
.filter(|object| {
if object.get("type").and_then(serde_json::Value::as_str)
!= Some("PipeWire:Interface:Link")
{
return false;
}
let Some(input_node) = object.pointer("/info/props/link.input.node") else {
return false;
};
input_node.as_u64().or_else(|| {
input_node
.as_str()
.and_then(|value| value.parse::<u64>().ok())
}) == Some(sink_id)
})
.cloned()
.collect())
}
/// Phase-0d graph assertion: the hidden mode reaches the real native sink,
/// but neither a Pulse default-monitor module nor any native link feeds it.
#[tokio::test]
#[ignore = "live: mutates the shared PipeWire graph; run alone with --test-threads=1"]
async fn live_desktop_excluding_sink_has_no_legacy_feed() {
let opts = opts(false, false, CaptureMode::DesktopExcluding, false);
let sink_name = crate::repair::plan::sink_name_for(std::process::id());
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
let (health, _) = health::channel();
let plan = CapturePlan::start(&opts, health.clone())
.await
.expect("start the hidden desktop-excluding plan");
let capture_sink = match &plan {
CapturePlan::DesktopExcluding { capture_sink } => capture_sink,
_ => panic!("hidden mode constructed the wrong capture-plan variant"),
};
assert_eq!(capture_sink.sink_name(), sink_name);
assert!(pulse_source_exists(capture_sink.monitor_name()));
let mut pulse = crate::repair::introspect::PulseSession::connect()
.expect("connect to the local Pulse server");
let modules = pulse.list_modules().expect("read the Pulse module table");
let feeding_modules: Vec<_> = modules
.into_iter()
.filter(|module| {
module.name == "module-loopback"
&& module.args.contains(&format!("sink={sink_name}"))
})
.collect();
assert!(
feeding_modules.is_empty(),
"DesktopExcluding must not construct a Pulse loopback into its sink: {feeding_modules:?}"
);
assert!(
incoming_links(&sink_name)
.expect("inspect incoming native links")
.is_empty(),
"phase 0d must leave the bare sink unfed until phase 6 owns native links"
);
plan.shutdown().await;
let deadline = Instant::now() + Duration::from_secs(2);
while pulse_source_exists(&format!("{sink_name}.monitor")) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
assert!(health.fault().is_none());
}
}