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
+40 -1
View File
@@ -34,7 +34,12 @@
//! *is* a real Link whose output node is the sink node itself (measured).
//! A port-granular walk would need a synthetic edge; a node-granular one
//! does not.
//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See
//! 3. **Hardware-device bridges** — a passive sink can feed a passive source
//! on the same physical Device through a mixer/loopback path that PipeWire
//! does not expose as a Link. The observer positively classifies both
//! terminals and retains their shared `device.id`; the walk conservatively
//! adds `sink → source` for that one Device.
//! 4. **Owner bridges** — the intra-process hop the graph cannot see. See
//! [`owner`]; this is the hard one.
//!
//! ## Stickiness
@@ -784,6 +789,40 @@ fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Se
_ => {}
}
}
// A physical sound device may route playback back into capture in its
// own mixer/firmware without publishing a PipeWire Link (HDA "Stereo
// Mix", USB loopback channels, vendor DSPs). Control-name inspection is
// neither portable nor proof of absence, so v1 fails closed: once a
// passive hardware sink is tainted, passive capture terminals exported
// by the same Device are downstream too.
//
// Both guards are load-bearing. `session_device` limits this to the
// observer's positive hardware-terminal allowlist, so an app-associated
// filter cannot invent a bridge. `device_id` limits it to one physical
// Device, so the shared WirePlumber client does not fuse every card.
let hardware_outputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
.nodes()
.filter(|node| {
node.props.session_device && matches!(node.role, MediaRole::Sink | MediaRole::Duplex)
})
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
.collect();
let hardware_inputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
.nodes()
.filter(|node| {
node.props.session_device && matches!(node.role, MediaRole::Source | MediaRole::Duplex)
})
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
.collect();
for (from, output_device) in hardware_outputs {
for &(to, input_device) in &hardware_inputs {
if from != to && output_device == input_device {
edges.entry(from).or_default().push(to);
receivers.insert(to);
}
}
}
for targets in edges.values_mut() {
targets.sort_unstable();
targets.dedup();