feat(host): build desktop audio exclusion foundation
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user