Files
fluxer-desktop-patched/fluxer_desktop/native/linux-audio-capture/src/routing.rs
T
brenden 682afacd30 Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
2026-07-01 18:22:43 -04:00

663 lines
20 KiB
Rust

#![allow(dead_code)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::{HashMap, HashSet};
pub type PropMap = HashMap<String, String>;
pub type PropPattern = PropMap;
pub const MEDIA_CLASS_PLAYBACK_STREAM: &str = "Stream/Output/Audio";
const TARGET_OBJECTS_PATTERN_KEY: &str = "fluxer.target.objects";
const DISPLAY_PATTERN_PREFIX: &str = "fluxer.display.";
#[derive(Debug, Default, Clone)]
pub struct SelfIdentity {
pub pids: HashSet<String>,
pub binaries: HashSet<String>,
pub display_names: HashSet<String>,
pub display_prefixes: Vec<String>,
}
impl SelfIdentity {
pub fn add_pid(&mut self, pid: impl Into<String>) {
let value = pid.into();
if value.is_empty() {
return;
}
self.pids.insert(value);
}
pub fn add_binary(&mut self, name: impl Into<String>) {
let value = name.into();
if value.is_empty() {
return;
}
self.binaries.insert(value);
}
pub fn add_display_name(&mut self, name: impl Into<String>) {
let value = name.into();
if value.is_empty() {
return;
}
self.display_names.insert(value);
}
pub fn add_display_prefix(&mut self, prefix: impl Into<String>) {
let value = prefix.into();
if value.is_empty() {
return;
}
self.display_prefixes.push(value);
}
pub fn matches(&self, properties: &PropMap) -> bool {
if let Some(raw) = properties.get("application.process.id")
&& self.pids.contains(raw)
{
return true;
}
if let Some(raw) = properties.get("pipewire.sec.pid")
&& self.pids.contains(raw)
{
return true;
}
if let Some(raw) = properties.get("application.process.binary")
&& contains_case_insensitive(&self.binaries, raw)
{
return true;
}
for key in [
"application.name",
"node.name",
"node.nick",
"node.description",
] {
if let Some(raw) = properties.get(key)
&& self.matches_display_identity(raw)
{
return true;
}
}
false
}
fn matches_display_identity(&self, raw: &str) -> bool {
contains_case_insensitive(&self.binaries, raw)
|| contains_case_insensitive(&self.display_names, raw)
|| self
.display_prefixes
.iter()
.any(|prefix| starts_with_case_insensitive(raw, prefix))
}
}
fn contains_case_insensitive(values: &HashSet<String>, needle: &str) -> bool {
values
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(needle))
}
fn starts_with_case_insensitive(value: &str, prefix: &str) -> bool {
value
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
#[derive(Debug, Default, Clone)]
pub struct RoutingRule {
pub include_when: Vec<PropPattern>,
pub never_when: Vec<PropPattern>,
pub pin_target_for: Vec<PropPattern>,
pub skip_hardware_devices: bool,
pub only_audio_sinks: bool,
pub only_default_audio_sink: bool,
}
pub fn matches_pattern(candidate: &PropMap, expected: &PropPattern) -> bool {
for (key, value) in expected {
if key.starts_with(DISPLAY_PATTERN_PREFIX) {
continue;
}
if key == TARGET_OBJECTS_PATTERN_KEY {
if !matches_target_object(candidate, value) {
return false;
}
continue;
}
match candidate.get(key) {
Some(actual) if actual == value => {}
_ => return false,
}
}
true
}
fn matches_target_object(candidate: &PropMap, expected_values: &str) -> bool {
let Some(actual) = candidate
.get("target.object")
.or_else(|| candidate.get("node.target"))
else {
return false;
};
expected_values
.split('\n')
.filter(|value| !value.is_empty())
.any(|expected| actual == expected)
}
pub fn matches_any(candidate: &PropMap, patterns: &[PropPattern]) -> bool {
patterns.iter().any(|p| matches_pattern(candidate, p))
}
pub fn should_route_node(
id: u32,
properties: &PropMap,
rule: &RoutingRule,
default_sink_name: &str,
default_sink_target_id: &str,
sink_global_id: u32,
self_identity: &SelfIdentity,
) -> bool {
if id == sink_global_id {
return false;
}
if self_identity.matches(properties) {
return false;
}
if matches_any(properties, &rule.never_when) {
return false;
}
if rule.skip_hardware_devices && properties.contains_key("device.id") {
return false;
}
let Some(class) = properties.get("media.class") else {
return false;
};
if class != MEDIA_CLASS_PLAYBACK_STREAM {
return false;
}
if !rule.include_when.is_empty() {
return matches_any(properties, &rule.include_when);
}
if rule.only_audio_sinks {
return !rule.only_default_audio_sink
|| targets_default_sink(properties, default_sink_name, default_sink_target_id);
}
false
}
pub fn targets_default_sink(
properties: &PropMap,
default_sink_name: &str,
default_sink_target_id: &str,
) -> bool {
if default_sink_name.is_empty() && default_sink_target_id.is_empty() {
return true;
}
let Some(target) = properties
.get("target.object")
.or_else(|| properties.get("node.target"))
else {
return true;
};
(!default_sink_name.is_empty() && target == default_sink_name)
|| (!default_sink_target_id.is_empty() && target == default_sink_target_id)
}
pub fn parse_default_sink_name(blob: &str) -> String {
let trimmed = blob.trim();
if !trimmed.starts_with('{') {
return String::new();
}
let bytes = trimmed.as_bytes();
let mut i = 1usize;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'"' {
return String::new();
}
i += 1;
let key_start = i;
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] == b'\\' {
i += 2;
} else {
i += 1;
}
}
if i >= bytes.len() {
return String::new();
}
let key = &trimmed[key_start..i];
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b':' {
return String::new();
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
return String::new();
}
if bytes[i] != b'"' {
let mut depth = 0usize;
while i < bytes.len() {
match bytes[i] {
b'{' | b'[' => depth += 1,
b'}' | b']' => {
if depth == 0 {
return String::new();
}
depth -= 1;
}
b',' if depth == 0 => break,
_ => {}
}
i += 1;
}
if i < bytes.len() && bytes[i] == b',' {
i += 1;
continue;
}
return String::new();
}
i += 1;
let val_start = i;
let mut buf = String::new();
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
let escaped = bytes[i + 1];
buf.push(escaped as char);
i += 2;
} else {
buf.push(bytes[i] as char);
i += 1;
}
}
if i >= bytes.len() {
return String::new();
}
if key == "name" {
if buf.len() == i - val_start {
return trimmed[val_start..i].to_string();
}
return buf;
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && bytes[i] == b',' {
i += 1;
}
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_map(entries: &[(&str, &str)]) -> PropMap {
entries
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
fn system_rule() -> RoutingRule {
RoutingRule {
skip_hardware_devices: true,
only_audio_sinks: true,
only_default_audio_sink: true,
..Default::default()
}
}
#[test]
fn empty_pattern_matches_any_candidate() {
let candidate = make_map(&[("application.name", "Example")]);
let empty: PropPattern = PropPattern::new();
assert!(matches_pattern(&candidate, &empty));
}
#[test]
fn missing_keys_and_mismatched_values_do_not_match() {
let candidate = make_map(&[("application.name", "Example")]);
let missing = make_map(&[("application.process.id", "1234")]);
let mismatched = make_map(&[("application.name", "Other")]);
assert!(!matches_pattern(&candidate, &missing));
assert!(!matches_pattern(&candidate, &mismatched));
}
#[test]
fn synthetic_target_object_pattern_matches_name_or_serial() {
let candidate = make_map(&[("target.object", "42")]);
let deprecated = make_map(&[("node.target", "alsa_output.foo")]);
let pattern = make_map(&[(TARGET_OBJECTS_PATTERN_KEY, "alsa_output.foo\n42")]);
let mismatch = make_map(&[(TARGET_OBJECTS_PATTERN_KEY, "alsa_output.foo\n99")]);
assert!(matches_pattern(&candidate, &pattern));
assert!(matches_pattern(&deprecated, &pattern));
assert!(!matches_pattern(&candidate, &mismatch));
}
#[test]
fn synthetic_display_pattern_keys_do_not_affect_routing() {
let candidate = make_map(&[("application.name", "Example")]);
let pattern = make_map(&[
("application.name", "Example"),
("fluxer.display.name", "Living room speakers"),
]);
assert!(matches_pattern(&candidate, &pattern));
}
#[test]
fn matches_any_requires_at_least_one_matching_pattern() {
let candidate = make_map(&[("application.name", "Example")]);
let patterns = vec![
make_map(&[("application.name", "Other")]),
make_map(&[("application.name", "Example")]),
];
assert!(matches_any(&candidate, &patterns));
assert!(!matches_any(&candidate, &[]));
}
#[test]
fn system_mode_routes_only_default_playback_streams() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let hdmi = "alsa_output.pci-0000_01_00.1.hdmi-stereo";
let stream = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", analog),
]);
let other = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", hdmi),
]);
let rule = system_rule();
assert!(should_route_node(
100, &stream, &rule, analog, "", 1, &identity,
));
assert!(!should_route_node(
101, &other, &rule, analog, "", 1, &identity,
));
}
#[test]
fn structural_self_identity_wins_over_include_rules() {
let mut identity = SelfIdentity::default();
identity.add_pid("4242");
identity.add_binary("fluxer");
identity.add_display_name("Fluxer Canary");
identity.add_display_prefix("Fluxer ");
let rule = RoutingRule {
include_when: vec![make_map(&[("application.process.id", "4242")])],
..Default::default()
};
let by_pid = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("application.process.id", "4242"),
]);
let by_binary = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("application.process.binary", "fluxer"),
]);
let by_description = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("node.description", "Fluxer Direct Capture (pid 4242)"),
]);
assert!(!should_route_node(
200, &by_pid, &rule, "", "", 0, &identity
));
assert!(!should_route_node(
201, &by_binary, &rule, "", "", 0, &identity,
));
assert!(!should_route_node(
202,
&by_description,
&rule,
"",
"",
0,
&identity,
));
}
#[test]
fn routing_refuses_non_playback_media_classes_even_when_included() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Recorder")])],
..Default::default()
};
let input_stream = make_map(&[
("media.class", "Stream/Input/Audio"),
("application.name", "Recorder"),
]);
let device = make_map(&[
("media.class", "Audio/Source"),
("application.name", "Recorder"),
]);
assert!(!should_route_node(
300,
&input_stream,
&rule,
"",
"",
0,
&identity,
));
assert!(!should_route_node(
301, &device, &rule, "", "", 0, &identity
));
}
#[test]
fn system_mode_accepts_untargeted_and_node_target_streams() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let untargeted = make_map(&[("media.class", MEDIA_CLASS_PLAYBACK_STREAM)]);
let deprecated = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("node.target", analog),
]);
let rule = system_rule();
assert!(should_route_node(
100,
&untargeted,
&rule,
analog,
"",
1,
&identity,
));
assert!(should_route_node(
101,
&deprecated,
&rule,
analog,
"",
1,
&identity,
));
}
#[test]
fn system_mode_accepts_default_sink_object_id_targets() {
let identity = SelfIdentity::default();
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
let by_name = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", analog),
]);
let by_id = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", "42"),
]);
let other = make_map(&[
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
("target.object", "99"),
]);
let rule = system_rule();
assert!(should_route_node(
100, &by_name, &rule, analog, "42", 1, &identity,
));
assert!(should_route_node(
101, &by_id, &rule, analog, "42", 1, &identity,
));
assert!(!should_route_node(
102, &other, &rule, analog, "42", 1, &identity,
));
}
#[test]
fn include_mode_honors_hardware_filtering_and_never_rules() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Firefox")])],
never_when: vec![make_map(&[("application.process.id", "999")])],
skip_hardware_devices: true,
..Default::default()
};
let app = make_map(&[
("application.name", "Firefox"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let hardware = make_map(&[("application.name", "Firefox"), ("device.id", "5")]);
let blocked = make_map(&[
("application.name", "Firefox"),
("application.process.id", "999"),
]);
assert!(should_route_node(10, &app, &rule, "", "", 1, &identity));
assert!(!should_route_node(
11, &hardware, &rule, "", "", 1, &identity
));
assert!(!should_route_node(
12, &blocked, &rule, "", "", 1, &identity
));
}
#[test]
fn include_mode_rejects_non_playback_nodes_that_match_the_include_filter() {
let identity = SelfIdentity::default();
let rule = RoutingRule {
include_when: vec![make_map(&[("application.name", "Chromium")])],
..Default::default()
};
let mic = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Source"),
]);
let sink = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Sink"),
]);
let input = make_map(&[
("application.name", "Chromium"),
("media.class", "Stream/Input/Audio"),
]);
let virtual_source = make_map(&[
("application.name", "Chromium"),
("media.class", "Audio/Source/Virtual"),
]);
let playback = make_map(&[
("application.name", "Chromium"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let unclassified = make_map(&[("application.name", "Chromium")]);
assert!(!should_route_node(20, &mic, &rule, "", "", 1, &identity));
assert!(!should_route_node(21, &sink, &rule, "", "", 1, &identity));
assert!(!should_route_node(22, &input, &rule, "", "", 1, &identity));
assert!(!should_route_node(
23,
&virtual_source,
&rule,
"",
"",
1,
&identity,
));
assert!(!should_route_node(
24,
&unclassified,
&rule,
"",
"",
1,
&identity,
));
assert!(should_route_node(
25, &playback, &rule, "", "", 1, &identity
));
}
#[test]
fn empty_rules_route_nothing_and_sink_id_is_excluded() {
let identity = SelfIdentity::default();
let app = make_map(&[
("application.name", "Foo"),
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
]);
let empty = RoutingRule::default();
assert!(!should_route_node(1, &app, &empty, "", "", 0, &identity));
let rule = system_rule();
assert!(!should_route_node(7, &app, &rule, "", "", 7, &identity));
}
#[test]
fn parse_default_sink_name_is_strict_and_tolerant() {
assert_eq!(
"alsa_output.foo",
parse_default_sink_name(r#"{"name":"alsa_output.foo","other":"bar"}"#),
);
assert_eq!("", parse_default_sink_name("not-json"));
assert_eq!("", parse_default_sink_name(r#"{"name":42}"#));
}
#[test]
fn self_identity_matches_across_documented_pipewire_keys() {
let mut identity = SelfIdentity::default();
identity.add_pid("1234");
identity.add_binary("fluxer");
identity.add_display_name("Fluxer Canary");
identity.add_display_prefix("Fluxer ");
let by_pid = make_map(&[("application.process.id", "1234")]);
let by_sec_pid = make_map(&[("pipewire.sec.pid", "1234")]);
let by_binary = make_map(&[("application.process.binary", "fluxer")]);
let by_app_name = make_map(&[("application.name", "fluxer")]);
let by_node_name = make_map(&[("node.name", "fluxer")]);
let by_node_nick = make_map(&[("node.nick", "Fluxer Canary")]);
let by_node_description = make_map(&[("node.description", "Fluxer app audio capture")]);
let stranger = make_map(&[("application.process.id", "9999")]);
assert!(identity.matches(&by_pid));
assert!(identity.matches(&by_sec_pid));
assert!(identity.matches(&by_binary));
assert!(identity.matches(&by_app_name));
assert!(identity.matches(&by_node_name));
assert!(identity.matches(&by_node_nick));
assert!(identity.matches(&by_node_description));
assert!(!identity.matches(&stranger));
}
}