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.
This commit is contained in:
@@ -0,0 +1,662 @@
|
||||
#![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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user