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:
Generated
+1164
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "fluxer_desktop_native"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[dependencies]
|
||||
sha2 = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.8"
|
||||
proptest = "1.11"
|
||||
tempfile = "3.27"
|
||||
|
||||
[[bench]]
|
||||
name = "native_core"
|
||||
harness = false
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"measured_at": "85e057a273fd",
|
||||
"host": "darwin-arm64-apple-silicon",
|
||||
"regression_budget_percent": 5.0,
|
||||
"criterion_args": {
|
||||
"warm_up_time_sec": 2,
|
||||
"measurement_time_sec": 5
|
||||
},
|
||||
"benches": {
|
||||
"routing/default_sink_playback_stream": {
|
||||
"median_ns": 41.6,
|
||||
"low_ns": 41.327,
|
||||
"high_ns": 41.862
|
||||
},
|
||||
"ring/fill_drain_1024": {
|
||||
"median_ns": 3866.6,
|
||||
"low_ns": 3844.2,
|
||||
"high_ns": 3891.2
|
||||
},
|
||||
"audio/mono_to_stereo_1s": {
|
||||
"median_ns": 1119800.0,
|
||||
"low_ns": 1112300.0,
|
||||
"high_ns": 1128900.0
|
||||
},
|
||||
"evdev/keymap_roundtrip_table": {
|
||||
"median_ns": 11119.0,
|
||||
"low_ns": 11054.0,
|
||||
"high_ns": 11194.0
|
||||
},
|
||||
"mac_process_tree/collect_512_chain": {
|
||||
"median_ns": 48667.0,
|
||||
"low_ns": 48397.0,
|
||||
"high_ns": 48975.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::hint::black_box;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use fluxer_desktop_native::input::ring::Ring;
|
||||
use fluxer_desktop_native::linux_audio::routing::{
|
||||
MEDIA_CLASS_PLAYBACK_STREAM, SelfIdentity, map, should_route_node, system_rule,
|
||||
};
|
||||
use fluxer_desktop_native::linux_evdev::keymap::{KEY_MAP, keycode_to_name, name_to_keycode};
|
||||
use fluxer_desktop_native::mac_app_audio::audio_converter::{
|
||||
AudioBuffer, AudioBufferListN, build_input_asbd, convert_buffer_list_to_interleaved_f32,
|
||||
};
|
||||
use fluxer_desktop_native::mac_app_audio::process_tree::{
|
||||
Info, collect_related_pids_with_resolver,
|
||||
};
|
||||
|
||||
fn bench_routing(c: &mut Criterion) {
|
||||
let self_identity = SelfIdentity::default();
|
||||
let rule = system_rule();
|
||||
let props = map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
(
|
||||
"target.object",
|
||||
"alsa_output.pci-0000_00_1f.3.analog-stereo",
|
||||
),
|
||||
]);
|
||||
c.bench_function("routing/default_sink_playback_stream", |b| {
|
||||
b.iter(|| {
|
||||
black_box(should_route_node(
|
||||
100,
|
||||
black_box(&props),
|
||||
black_box(&rule),
|
||||
"alsa_output.pci-0000_00_1f.3.analog-stereo",
|
||||
"",
|
||||
1,
|
||||
&self_identity,
|
||||
))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_ring(c: &mut Criterion) {
|
||||
c.bench_function("ring/fill_drain_1024", |b| {
|
||||
b.iter(|| {
|
||||
let mut ring: Ring<u32, 1024> = Ring::new();
|
||||
for index in 0..1024 {
|
||||
let slot = ring.claim().unwrap() as usize;
|
||||
ring.slots[slot] = index;
|
||||
}
|
||||
let mut sum = 0_u32;
|
||||
while let Some(slot) = ring.pop() {
|
||||
sum = sum.wrapping_add(ring.slots[slot as usize]);
|
||||
ring.release();
|
||||
}
|
||||
black_box(sum)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_audio_convert(c: &mut Criterion) {
|
||||
let samples: Vec<f32> = (0..48_000).map(|i| (i as f32 / 48_000.0).sin()).collect();
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(1, &samples)],
|
||||
};
|
||||
let asbd = build_input_asbd(48_000.0, 1, false);
|
||||
let mut out = vec![0.0_f32; samples.len() * 2];
|
||||
c.bench_function("audio/mono_to_stereo_1s", |b| {
|
||||
b.iter(|| {
|
||||
black_box(
|
||||
convert_buffer_list_to_interleaved_f32(
|
||||
asbd,
|
||||
&list,
|
||||
samples.len() as u32,
|
||||
48_000.0,
|
||||
2,
|
||||
&mut out,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_evdev_keymap(c: &mut Criterion) {
|
||||
c.bench_function("evdev/keymap_roundtrip_table", |b| {
|
||||
b.iter(|| {
|
||||
let mut sum = 0_u16;
|
||||
for entry in KEY_MAP {
|
||||
sum ^= black_box(name_to_keycode(black_box(entry.name)));
|
||||
black_box(keycode_to_name(black_box(entry.code)));
|
||||
}
|
||||
black_box(sum)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_process_tree_collect(c: &mut Criterion) {
|
||||
let infos: Vec<Info> = (0..512_i32)
|
||||
.map(|index| Info {
|
||||
pid: 10_000 + index,
|
||||
parent_pid: if index == 0 { 1 } else { 10_000 + index - 1 },
|
||||
process_group_id: 10_000,
|
||||
})
|
||||
.collect();
|
||||
let candidates: Vec<i32> = infos.iter().map(|info| info.pid).rev().collect();
|
||||
c.bench_function("mac_process_tree/collect_512_chain", |b| {
|
||||
b.iter(|| {
|
||||
let resolver = |pid| infos.iter().copied().find(|info| info.pid == pid);
|
||||
black_box(collect_related_pids_with_resolver(
|
||||
10_000,
|
||||
Some(infos[0]),
|
||||
black_box(&candidates),
|
||||
512,
|
||||
resolver,
|
||||
))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_routing,
|
||||
bench_ring,
|
||||
bench_audio_convert,
|
||||
bench_evdev_keymap,
|
||||
bench_process_tree_collect
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::thread;
|
||||
|
||||
use fluxer_desktop_native::input::ring::Ring;
|
||||
use fluxer_desktop_native::linux_evdev::event::{EV_KEY, InputEvent, parse_input_events};
|
||||
use fluxer_desktop_native::mac_app_audio::process_tree::{
|
||||
Info, collect_related_pids_with_resolver,
|
||||
};
|
||||
use fluxer_desktop_native::mac_app_audio::source_state::{Machine, State};
|
||||
|
||||
fn stress_source_state() {
|
||||
for _ in 0..10_000 {
|
||||
let machine = Arc::new(Machine::new());
|
||||
machine.request_start().expect("request start");
|
||||
let stop_wins = Arc::new(AtomicU64::new(0));
|
||||
let run_machine = Arc::clone(&machine);
|
||||
let stop_machine = Arc::clone(&machine);
|
||||
let stop_counter = Arc::clone(&stop_wins);
|
||||
let run_thread = thread::spawn(move || {
|
||||
let _ = run_machine.mark_running();
|
||||
});
|
||||
let stop_thread = thread::spawn(move || {
|
||||
for _ in 0..1000 {
|
||||
if stop_machine.request_stop().is_ok() {
|
||||
stop_counter.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
});
|
||||
run_thread.join().expect("run thread");
|
||||
stop_thread.join().expect("stop thread");
|
||||
if machine.current() == State::Running {
|
||||
machine.request_stop().expect("request stop after run");
|
||||
}
|
||||
assert_eq!(State::Stopping, machine.current());
|
||||
assert!(stop_wins.load(Ordering::Relaxed) <= 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn stress_ring() {
|
||||
let mut ring: Ring<u64, 4096> = Ring::new();
|
||||
for cycle in 0..2048_u64 {
|
||||
for index in 0..4096_u64 {
|
||||
let slot = ring.claim().expect("slot") as usize;
|
||||
ring.slots[slot] = cycle.wrapping_mul(4096).wrapping_add(index);
|
||||
}
|
||||
assert!(ring.claim().is_none());
|
||||
for index in 0..4096_u64 {
|
||||
let slot = ring.pop().expect("slot") as usize;
|
||||
assert_eq!(
|
||||
cycle.wrapping_mul(4096).wrapping_add(index),
|
||||
ring.slots[slot]
|
||||
);
|
||||
ring.release();
|
||||
}
|
||||
assert!(ring.pop().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn stress_evdev_parser() {
|
||||
let mut bytes = Vec::with_capacity(InputEvent::BYTE_LEN * 100_000);
|
||||
for index in 0..100_000_i32 {
|
||||
let event = InputEvent {
|
||||
time_sec: i64::from(index),
|
||||
time_usec: i64::from(index * 10),
|
||||
event_type: EV_KEY,
|
||||
code: 30,
|
||||
value: index & 1,
|
||||
};
|
||||
bytes.extend_from_slice(&event.time_sec.to_ne_bytes());
|
||||
bytes.extend_from_slice(&event.time_usec.to_ne_bytes());
|
||||
bytes.extend_from_slice(&event.event_type.to_ne_bytes());
|
||||
bytes.extend_from_slice(&event.code.to_ne_bytes());
|
||||
bytes.extend_from_slice(&event.value.to_ne_bytes());
|
||||
}
|
||||
let mut count = 0_usize;
|
||||
for event in parse_input_events(&bytes) {
|
||||
assert_eq!(EV_KEY, event.event_type);
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(100_000, count);
|
||||
}
|
||||
|
||||
fn stress_process_tree() {
|
||||
let infos: Vec<Info> = (0..1024_i32)
|
||||
.map(|index| Info {
|
||||
pid: 20_000 + index,
|
||||
parent_pid: if index == 0 { 1 } else { 20_000 + index - 1 },
|
||||
process_group_id: 20_000,
|
||||
})
|
||||
.collect();
|
||||
let candidates: Vec<i32> = infos.iter().map(|info| info.pid).rev().collect();
|
||||
for _ in 0..1000 {
|
||||
let resolver = |pid| infos.iter().copied().find(|info| info.pid == pid);
|
||||
let related =
|
||||
collect_related_pids_with_resolver(20_000, Some(infos[0]), &candidates, 1024, resolver);
|
||||
assert_eq!(1024, related.len());
|
||||
assert_eq!(20_000, related[0]);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
stress_source_state();
|
||||
stress_ring();
|
||||
stress_evdev_parser();
|
||||
stress_process_tree();
|
||||
println!("native core stress completed");
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_desktop_native"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_desktop_native_fuzz"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"fluxer_desktop_native",
|
||||
"libfuzzer-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "fluxer_desktop_native_fuzz"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
arbitrary = {version = "1.4", features = ["derive"]}
|
||||
fluxer_desktop_native = {path = ".."}
|
||||
libfuzzer-sys = "0.4"
|
||||
|
||||
[[bin]]
|
||||
name = "routing"
|
||||
path = "fuzz_targets/routing.rs"
|
||||
test = false
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "pid_payload"
|
||||
path = "fuzz_targets/pid_payload.rs"
|
||||
test = false
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "audio_converter"
|
||||
path = "fuzz_targets/audio_converter.rs"
|
||||
test = false
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "evdev_event"
|
||||
path = "fuzz_targets/evdev_event.rs"
|
||||
test = false
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "process_tree"
|
||||
path = "fuzz_targets/process_tree.rs"
|
||||
test = false
|
||||
doc = false
|
||||
@@ -0,0 +1,40 @@
|
||||
#![no_main]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use fluxer_desktop_native::mac_app_audio::audio_converter::{
|
||||
AudioBuffer, AudioBufferListN, build_input_asbd, convert_buffer_list_to_interleaved_f32,
|
||||
};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct Input {
|
||||
samples: Vec<f32>,
|
||||
sample_rate: f64,
|
||||
output_rate: f64,
|
||||
channels: u8,
|
||||
}
|
||||
|
||||
fuzz_target!(|input: Input| {
|
||||
let channels = u32::from(input.channels.clamp(1, 2));
|
||||
let sample_rate = if input.sample_rate.is_finite() && input.sample_rate > 0.0 {
|
||||
input.sample_rate.min(384_000.0)
|
||||
} else {
|
||||
48_000.0
|
||||
};
|
||||
let output_rate = if input.output_rate.is_finite() && input.output_rate > 0.0 {
|
||||
input.output_rate.min(384_000.0)
|
||||
} else {
|
||||
48_000.0
|
||||
};
|
||||
let frame_count = (input.samples.len() as u32 / channels).min(4096);
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(channels, &input.samples)],
|
||||
};
|
||||
let asbd = build_input_asbd(sample_rate, channels, false);
|
||||
let mut out = vec![0.0_f32; frame_count as usize * 4 + 16];
|
||||
let _ =
|
||||
convert_buffer_list_to_interleaved_f32(asbd, &list, frame_count, output_rate, 2, &mut out);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
#![no_main]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_desktop_native::linux_evdev::event::{
|
||||
InputEvent, parse_input_event, parse_input_events,
|
||||
};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = parse_input_event(data);
|
||||
let mut count = 0_usize;
|
||||
for event in parse_input_events(data) {
|
||||
let _ = event.time_sec ^ event.time_usec;
|
||||
let _ = event.event_type ^ event.code;
|
||||
let _ = event.value;
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(data.len() / InputEvent::BYTE_LEN, count);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
#![no_main]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_desktop_native::linux_portals::pid_payload::parse_shell_eval_pid_payload;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(payload) = std::str::from_utf8(data) {
|
||||
let _ = parse_shell_eval_pid_payload(payload);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
#![no_main]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use fluxer_desktop_native::mac_app_audio::process_tree::{
|
||||
Info, collect_related_pids_with_resolver, is_same_launch_tree_with_resolver,
|
||||
};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct RawInfo {
|
||||
pid: i32,
|
||||
parent_pid: i32,
|
||||
process_group_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct Input {
|
||||
target_pid: i32,
|
||||
max_count: u8,
|
||||
infos: Vec<RawInfo>,
|
||||
candidates: Vec<i32>,
|
||||
}
|
||||
|
||||
fuzz_target!(|input: Input| {
|
||||
let infos: Vec<Info> = input
|
||||
.infos
|
||||
.into_iter()
|
||||
.take(128)
|
||||
.map(|raw| Info {
|
||||
pid: raw.pid,
|
||||
parent_pid: raw.parent_pid,
|
||||
process_group_id: raw.process_group_id,
|
||||
})
|
||||
.collect();
|
||||
let candidates: Vec<i32> = input.candidates.into_iter().take(128).collect();
|
||||
let target_info = infos
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|info| info.pid == input.target_pid);
|
||||
let resolver = |pid| infos.iter().copied().find(|info| info.pid == pid);
|
||||
let related = collect_related_pids_with_resolver(
|
||||
input.target_pid,
|
||||
target_info,
|
||||
&candidates,
|
||||
usize::from(input.max_count),
|
||||
resolver,
|
||||
);
|
||||
assert!(related.len() <= usize::from(input.max_count));
|
||||
if input.target_pid <= 0 || input.max_count == 0 {
|
||||
assert!(related.is_empty());
|
||||
} else {
|
||||
assert_eq!(Some(&input.target_pid), related.first());
|
||||
}
|
||||
|
||||
for candidate in candidates.into_iter().take(16) {
|
||||
let resolver = |pid| infos.iter().copied().find(|info| info.pid == pid);
|
||||
let _ =
|
||||
is_same_launch_tree_with_resolver(candidate, input.target_pid, target_info, resolver);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
#![no_main]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use fluxer_desktop_native::linux_audio::routing::{
|
||||
MEDIA_CLASS_PLAYBACK_STREAM, RoutingRule, SelfIdentity, should_route_node,
|
||||
};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct Input {
|
||||
id: u32,
|
||||
sink_id: u32,
|
||||
default_name: String,
|
||||
default_id: String,
|
||||
include_name: String,
|
||||
app_name: String,
|
||||
process_id: String,
|
||||
binary: String,
|
||||
has_device_id: bool,
|
||||
media_class_is_playback: bool,
|
||||
}
|
||||
|
||||
fuzz_target!(|input: Input| {
|
||||
let mut props = BTreeMap::new();
|
||||
props.insert(
|
||||
"media.class".to_owned(),
|
||||
if input.media_class_is_playback {
|
||||
MEDIA_CLASS_PLAYBACK_STREAM.to_owned()
|
||||
} else {
|
||||
"Audio/Source".to_owned()
|
||||
},
|
||||
);
|
||||
props.insert("application.name".to_owned(), input.app_name);
|
||||
props.insert("application.process.id".to_owned(), input.process_id);
|
||||
props.insert("application.process.binary".to_owned(), input.binary);
|
||||
props.insert("target.object".to_owned(), input.default_name.clone());
|
||||
if input.has_device_id {
|
||||
props.insert("device.id".to_owned(), "5".to_owned());
|
||||
}
|
||||
let mut include = BTreeMap::new();
|
||||
include.insert("application.name".to_owned(), input.include_name);
|
||||
let rule = RoutingRule {
|
||||
include_when: vec![include],
|
||||
skip_hardware_devices: true,
|
||||
..RoutingRule::default()
|
||||
};
|
||||
let self_identity = SelfIdentity::default();
|
||||
let _ = should_route_node(
|
||||
input.id,
|
||||
&props,
|
||||
&rule,
|
||||
&input.default_name,
|
||||
&input.default_id,
|
||||
input.sink_id,
|
||||
&self_identity,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const DIRECT_CAPTURE_SAMPLE_RATE: u32 = 48_000;
|
||||
pub const DIRECT_CAPTURE_CHANNELS: u32 = 2;
|
||||
pub const DIRECT_CAPTURE_MAX_SAMPLES: usize =
|
||||
DIRECT_CAPTURE_SAMPLE_RATE as usize * DIRECT_CAPTURE_CHANNELS as usize * 2;
|
||||
pub const DIRECT_CAPTURE_MAX_READ_SAMPLES: usize =
|
||||
DIRECT_CAPTURE_SAMPLE_RATE as usize * DIRECT_CAPTURE_CHANNELS as usize / 10;
|
||||
pub const MAX_ROUTING_RULE_PATTERNS: u32 = 64;
|
||||
pub const MAX_ROUTING_RULE_KEYS_PER_PATTERN: u32 = 32;
|
||||
pub const MAX_ROUTING_RULE_KEY_LENGTH: usize = 128;
|
||||
pub const MAX_ROUTING_RULE_VALUE_LENGTH: usize = 512;
|
||||
pub const MAX_INVENTORY_FIELDS: u32 = 32;
|
||||
pub const MAX_INVENTORY_FIELD_LENGTH: usize = 128;
|
||||
|
||||
const _: () = {
|
||||
assert!(MAX_ROUTING_RULE_PATTERNS > 0);
|
||||
assert!(MAX_ROUTING_RULE_KEYS_PER_PATTERN > 0);
|
||||
assert!(MAX_ROUTING_RULE_KEY_LENGTH > 0);
|
||||
assert!(MAX_ROUTING_RULE_VALUE_LENGTH >= MAX_ROUTING_RULE_KEY_LENGTH);
|
||||
assert!(MAX_INVENTORY_FIELDS <= MAX_ROUTING_RULE_KEYS_PER_PATTERN);
|
||||
assert!(MAX_INVENTORY_FIELD_LENGTH <= MAX_ROUTING_RULE_KEY_LENGTH);
|
||||
};
|
||||
|
||||
pub fn whole_frame_sample_count(sample_count: usize, channels: u32) -> usize {
|
||||
if channels == 0 {
|
||||
return 0;
|
||||
}
|
||||
let channel_count = channels as usize;
|
||||
sample_count - (sample_count % channel_count)
|
||||
}
|
||||
|
||||
pub fn direct_whole_frame_sample_count(sample_count: usize) -> usize {
|
||||
whole_frame_sample_count(sample_count, DIRECT_CAPTURE_CHANNELS)
|
||||
}
|
||||
|
||||
pub fn bounded_direct_read_sample_count(available: usize) -> usize {
|
||||
direct_whole_frame_sample_count(available.min(DIRECT_CAPTURE_MAX_READ_SAMPLES))
|
||||
}
|
||||
|
||||
pub fn bounded_direct_append_slice(input: &[f32]) -> &[f32] {
|
||||
let whole = direct_whole_frame_sample_count(input.len());
|
||||
let framed = &input[..whole];
|
||||
if framed.len() > DIRECT_CAPTURE_MAX_SAMPLES {
|
||||
&framed[framed.len() - DIRECT_CAPTURE_MAX_SAMPLES..]
|
||||
} else {
|
||||
framed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn whole_frame_sample_count_trims_incomplete_channel_frames() {
|
||||
assert_eq!(0, whole_frame_sample_count(1, 2));
|
||||
assert_eq!(2, whole_frame_sample_count(2, 2));
|
||||
assert_eq!(4, whole_frame_sample_count(5, 2));
|
||||
assert_eq!(6, whole_frame_sample_count(7, 3));
|
||||
assert_eq!(0, whole_frame_sample_count(7, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_read_count_is_bounded_and_stereo_aligned() {
|
||||
assert_eq!(0, bounded_direct_read_sample_count(1));
|
||||
assert_eq!(2, bounded_direct_read_sample_count(3));
|
||||
assert_eq!(
|
||||
DIRECT_CAPTURE_MAX_READ_SAMPLES,
|
||||
bounded_direct_read_sample_count(DIRECT_CAPTURE_MAX_READ_SAMPLES + 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_append_slice_keeps_only_complete_stereo_samples_within_queue_cap() {
|
||||
let samples = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let trimmed = bounded_direct_append_slice(&samples);
|
||||
assert_eq!(4, trimmed.len());
|
||||
assert_eq!(&samples[..4], trimmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod contract;
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod notify_parse;
|
||||
@@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NotifyReply {
|
||||
pub id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ActionInvokedSignal {
|
||||
pub id: u32,
|
||||
pub action_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClosedSignal {
|
||||
pub id: u32,
|
||||
pub reason: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
InvalidReply,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FakeArg<'a> {
|
||||
U32(u32),
|
||||
String(&'a str),
|
||||
}
|
||||
|
||||
pub fn parse_action_invoked_from_args(
|
||||
args: &[FakeArg<'_>],
|
||||
) -> Result<ActionInvokedSignal, ParseError> {
|
||||
match args {
|
||||
[FakeArg::U32(id), FakeArg::String(action_key), ..] => Ok(ActionInvokedSignal {
|
||||
id: *id,
|
||||
action_key: (*action_key).to_owned(),
|
||||
}),
|
||||
_ => Err(ParseError::InvalidReply),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_closed_from_args(args: &[FakeArg<'_>]) -> Result<ClosedSignal, ParseError> {
|
||||
match args {
|
||||
[FakeArg::U32(id), FakeArg::U32(reason), ..] => Ok(ClosedSignal {
|
||||
id: *id,
|
||||
reason: *reason,
|
||||
}),
|
||||
_ => Err(ParseError::InvalidReply),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_notify_reply_from_args(args: &[FakeArg<'_>]) -> Result<NotifyReply, ParseError> {
|
||||
match args {
|
||||
[FakeArg::U32(id), ..] => Ok(NotifyReply { id: *id }),
|
||||
_ => Err(ParseError::InvalidReply),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notify_reply_valid_u32_yields_id() {
|
||||
let reply = parse_notify_reply_from_args(&[FakeArg::U32(42)]).unwrap();
|
||||
assert_eq!(42, reply.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_reply_rejects_empty_body() {
|
||||
assert_eq!(
|
||||
Err(ParseError::InvalidReply),
|
||||
parse_notify_reply_from_args(&[])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_reply_rejects_wrong_type() {
|
||||
assert_eq!(
|
||||
Err(ParseError::InvalidReply),
|
||||
parse_notify_reply_from_args(&[FakeArg::String("wrong")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_invoked_valid_body() {
|
||||
let sig =
|
||||
parse_action_invoked_from_args(&[FakeArg::U32(7), FakeArg::String("default")]).unwrap();
|
||||
assert_eq!(7, sig.id);
|
||||
assert_eq!("default", sig.action_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_invoked_rejects_swapped_types() {
|
||||
assert_eq!(
|
||||
Err(ParseError::InvalidReply),
|
||||
parse_action_invoked_from_args(&[FakeArg::String("x"), FakeArg::String("y")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_closed_id_and_reason() {
|
||||
let sig = parse_closed_from_args(&[FakeArg::U32(11), FakeArg::U32(2)]).unwrap();
|
||||
assert_eq!(11, sig.id);
|
||||
assert_eq!(2, sig.reason);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_closed_rejects_too_short() {
|
||||
assert_eq!(
|
||||
Err(ParseError::InvalidReply),
|
||||
parse_closed_from_args(&[FakeArg::U32(11)])
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WheelDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl WheelDirection {
|
||||
pub fn delta_x(self) -> i32 {
|
||||
match self {
|
||||
Self::Left => -120,
|
||||
Self::Right => 120,
|
||||
Self::Up | Self::Down => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delta_y(self) -> i32 {
|
||||
match self {
|
||||
Self::Up => -120,
|
||||
Self::Down => 120,
|
||||
Self::Left | Self::Right => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MouseClassification {
|
||||
Button(u8),
|
||||
Wheel(WheelDirection),
|
||||
Ignored,
|
||||
}
|
||||
|
||||
pub fn classify(x11_button: u32) -> MouseClassification {
|
||||
match x11_button {
|
||||
1 => MouseClassification::Button(0),
|
||||
2 => MouseClassification::Button(1),
|
||||
3 => MouseClassification::Button(2),
|
||||
4 => MouseClassification::Wheel(WheelDirection::Up),
|
||||
5 => MouseClassification::Wheel(WheelDirection::Down),
|
||||
6 => MouseClassification::Wheel(WheelDirection::Left),
|
||||
7 => MouseClassification::Wheel(WheelDirection::Right),
|
||||
8 => MouseClassification::Button(3),
|
||||
9 => MouseClassification::Button(4),
|
||||
_ => MouseClassification::Ignored,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn primary_buttons_map_to_browser_indices() {
|
||||
assert_eq!(MouseClassification::Button(0), classify(1));
|
||||
assert_eq!(MouseClassification::Button(1), classify(2));
|
||||
assert_eq!(MouseClassification::Button(2), classify(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_wheel_produces_delta_y_with_120_step() {
|
||||
assert_eq!(-120, WheelDirection::Up.delta_y());
|
||||
assert_eq!(120, WheelDirection::Down.delta_y());
|
||||
assert_eq!(0, WheelDirection::Up.delta_x());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_wheel_produces_delta_x_with_120_step() {
|
||||
assert_eq!(-120, WheelDirection::Left.delta_x());
|
||||
assert_eq!(120, WheelDirection::Right.delta_x());
|
||||
assert_eq!(0, WheelDirection::Left.delta_y());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_forward_buttons_map_to_3_and_4() {
|
||||
assert_eq!(MouseClassification::Button(3), classify(8));
|
||||
assert_eq!(MouseClassification::Button(4), classify(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_button_numbers_are_ignored_not_silently_misrouted() {
|
||||
assert_eq!(MouseClassification::Ignored, classify(0));
|
||||
assert_eq!(MouseClassification::Ignored, classify(15));
|
||||
assert_eq!(MouseClassification::Ignored, classify(255));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u32)]
|
||||
pub enum CgEventType {
|
||||
LeftMouseDown = 1,
|
||||
LeftMouseUp = 2,
|
||||
RightMouseDown = 3,
|
||||
RightMouseUp = 4,
|
||||
MouseMoved = 5,
|
||||
LeftMouseDragged = 6,
|
||||
RightMouseDragged = 7,
|
||||
KeyDown = 10,
|
||||
KeyUp = 11,
|
||||
FlagsChanged = 12,
|
||||
ScrollWheel = 22,
|
||||
OtherMouseDown = 25,
|
||||
OtherMouseUp = 26,
|
||||
OtherMouseDragged = 27,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Classification {
|
||||
Button(u8),
|
||||
Ignored,
|
||||
}
|
||||
|
||||
pub fn classify(event_type: CgEventType, other_button: u32) -> Classification {
|
||||
match event_type {
|
||||
CgEventType::LeftMouseDown | CgEventType::LeftMouseUp => Classification::Button(0),
|
||||
CgEventType::RightMouseDown | CgEventType::RightMouseUp => Classification::Button(2),
|
||||
CgEventType::OtherMouseDown | CgEventType::OtherMouseUp => match other_button {
|
||||
2 => Classification::Button(1),
|
||||
3 => Classification::Button(3),
|
||||
4 => Classification::Button(4),
|
||||
_ => Classification::Ignored,
|
||||
},
|
||||
_ => Classification::Ignored,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_down(event_type: CgEventType) -> bool {
|
||||
matches!(
|
||||
event_type,
|
||||
CgEventType::LeftMouseDown | CgEventType::RightMouseDown | CgEventType::OtherMouseDown
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn left_and_right_buttons_map_to_0_and_2() {
|
||||
assert_eq!(
|
||||
Classification::Button(0),
|
||||
classify(CgEventType::LeftMouseDown, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
Classification::Button(2),
|
||||
classify(CgEventType::RightMouseUp, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_button_maps_to_1() {
|
||||
assert_eq!(
|
||||
Classification::Button(1),
|
||||
classify(CgEventType::OtherMouseDown, 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_forward_map_to_3_and_4() {
|
||||
assert_eq!(
|
||||
Classification::Button(3),
|
||||
classify(CgEventType::OtherMouseDown, 3)
|
||||
);
|
||||
assert_eq!(
|
||||
Classification::Button(4),
|
||||
classify(CgEventType::OtherMouseUp, 4)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_other_button_is_ignored_not_silently_misrouted() {
|
||||
assert_eq!(
|
||||
Classification::Ignored,
|
||||
classify(CgEventType::OtherMouseDown, 99)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_down_distinguishes_press_from_release() {
|
||||
assert!(is_down(CgEventType::LeftMouseDown));
|
||||
assert!(!is_down(CgEventType::LeftMouseUp));
|
||||
assert!(is_down(CgEventType::OtherMouseDown));
|
||||
assert!(!is_down(CgEventType::MouseMoved));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod keymap;
|
||||
pub mod linux_mouse;
|
||||
pub mod macos_mouse;
|
||||
pub mod modifiers;
|
||||
pub mod ring;
|
||||
pub mod windows_mouse;
|
||||
pub mod x11;
|
||||
@@ -0,0 +1,216 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Modifiers {
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
pub shift: bool,
|
||||
pub meta: bool,
|
||||
}
|
||||
|
||||
pub mod linux {
|
||||
use super::Modifiers;
|
||||
|
||||
pub const SHIFT_MASK: u32 = 1 << 0;
|
||||
pub const CONTROL_MASK: u32 = 1 << 2;
|
||||
pub const MOD1_MASK: u32 = 1 << 3;
|
||||
pub const MOD4_MASK: u32 = 1 << 6;
|
||||
|
||||
pub fn from_state(state: u32) -> Modifiers {
|
||||
Modifiers {
|
||||
ctrl: (state & CONTROL_MASK) != 0,
|
||||
alt: (state & MOD1_MASK) != 0,
|
||||
shift: (state & SHIFT_MASK) != 0,
|
||||
meta: (state & MOD4_MASK) != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod macos {
|
||||
use super::Modifiers;
|
||||
|
||||
pub const SHIFT_MASK: u64 = 1 << 17;
|
||||
pub const CONTROL_MASK: u64 = 1 << 18;
|
||||
pub const ALTERNATE_MASK: u64 = 1 << 19;
|
||||
pub const COMMAND_MASK: u64 = 1 << 20;
|
||||
|
||||
pub fn from_flags(flags: u64) -> Modifiers {
|
||||
Modifiers {
|
||||
ctrl: (flags & CONTROL_MASK) != 0,
|
||||
alt: (flags & ALTERNATE_MASK) != 0,
|
||||
shift: (flags & SHIFT_MASK) != 0,
|
||||
meta: (flags & COMMAND_MASK) != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod windows {
|
||||
use super::Modifiers;
|
||||
|
||||
pub const HIGH_BIT: u16 = 0x8000;
|
||||
|
||||
pub fn from_sampled(
|
||||
shift_state: u16,
|
||||
ctrl_state: u16,
|
||||
alt_state: u16,
|
||||
lwin_state: u16,
|
||||
rwin_state: u16,
|
||||
) -> Modifiers {
|
||||
Modifiers {
|
||||
shift: (shift_state & HIGH_BIT) != 0,
|
||||
ctrl: (ctrl_state & HIGH_BIT) != 0,
|
||||
alt: (alt_state & HIGH_BIT) != 0,
|
||||
meta: ((lwin_state | rwin_state) & HIGH_BIT) != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linux_no_bits_all_modifiers_false() {
|
||||
assert_eq!(Modifiers::default(), linux::from_state(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_shift_mask_alone_sets_only_shift() {
|
||||
assert_eq!(
|
||||
Modifiers {
|
||||
shift: true,
|
||||
..Modifiers::default()
|
||||
},
|
||||
linux::from_state(linux::SHIFT_MASK)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_ctrl_alt_shift_meta_combo_all_true() {
|
||||
assert_eq!(
|
||||
Modifiers {
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
shift: true,
|
||||
meta: true,
|
||||
},
|
||||
linux::from_state(
|
||||
linux::SHIFT_MASK | linux::CONTROL_MASK | linux::MOD1_MASK | linux::MOD4_MASK
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_lockmask_and_numlock_are_ignored() {
|
||||
assert_eq!(Modifiers::default(), linux::from_state((1 << 1) | (1 << 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_mod1_mapped_to_alt_mod4_mapped_to_meta() {
|
||||
let a = linux::from_state(linux::MOD1_MASK);
|
||||
assert!(a.alt && !a.meta);
|
||||
let b = linux::from_state(linux::MOD4_MASK);
|
||||
assert!(b.meta && !b.alt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_no_flags_all_false() {
|
||||
assert_eq!(Modifiers::default(), macos::from_flags(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_command_alone_sets_only_meta() {
|
||||
assert_eq!(
|
||||
Modifiers {
|
||||
meta: true,
|
||||
..Modifiers::default()
|
||||
},
|
||||
macos::from_flags(macos::COMMAND_MASK)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_option_alone_sets_only_alt() {
|
||||
assert_eq!(
|
||||
Modifiers {
|
||||
alt: true,
|
||||
..Modifiers::default()
|
||||
},
|
||||
macos::from_flags(macos::ALTERNATE_MASK)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_cmd_shift_combo() {
|
||||
assert_eq!(
|
||||
Modifiers {
|
||||
shift: true,
|
||||
meta: true,
|
||||
..Modifiers::default()
|
||||
},
|
||||
macos::from_flags(macos::COMMAND_MASK | macos::SHIFT_MASK)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_all_four_modifiers_together() {
|
||||
assert_eq!(
|
||||
Modifiers {
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
shift: true,
|
||||
meta: true,
|
||||
},
|
||||
macos::from_flags(
|
||||
macos::SHIFT_MASK
|
||||
| macos::CONTROL_MASK
|
||||
| macos::ALTERNATE_MASK
|
||||
| macos::COMMAND_MASK
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_unrelated_high_bits_ignored() {
|
||||
assert_eq!(Modifiers::default(), macos::from_flags(0xff << 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_no_high_bits_all_modifiers_false() {
|
||||
assert_eq!(Modifiers::default(), windows::from_sampled(0, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_low_bit_only_state_ignored() {
|
||||
assert_eq!(Modifiers::default(), windows::from_sampled(1, 1, 1, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_shift_only() {
|
||||
assert_eq!(
|
||||
Modifiers {
|
||||
shift: true,
|
||||
..Modifiers::default()
|
||||
},
|
||||
windows::from_sampled(windows::HIGH_BIT, 0, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_either_win_key_sets_meta() {
|
||||
assert!(windows::from_sampled(0, 0, 0, windows::HIGH_BIT, 0).meta);
|
||||
assert!(windows::from_sampled(0, 0, 0, 0, windows::HIGH_BIT).meta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_all_four_modifiers_held() {
|
||||
let m = windows::from_sampled(
|
||||
windows::HIGH_BIT,
|
||||
windows::HIGH_BIT,
|
||||
windows::HIGH_BIT,
|
||||
windows::HIGH_BIT,
|
||||
0,
|
||||
);
|
||||
assert!(m.ctrl && m.alt && m.shift && m.meta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Ring<T: Copy + Default, const CAPACITY: usize> {
|
||||
pub slots: [T; CAPACITY],
|
||||
head: AtomicU64,
|
||||
tail: AtomicU64,
|
||||
dropped: AtomicU64,
|
||||
}
|
||||
|
||||
impl<T: Copy + Default, const CAPACITY: usize> Default for Ring<T, CAPACITY> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy + Default, const CAPACITY: usize> Ring<T, CAPACITY> {
|
||||
const MASK: u64 = CAPACITY as u64 - 1;
|
||||
|
||||
pub fn new() -> Self {
|
||||
assert!(
|
||||
CAPACITY > 0 && CAPACITY.is_power_of_two(),
|
||||
"Ring capacity must be a power of two"
|
||||
);
|
||||
Self {
|
||||
slots: [T::default(); CAPACITY],
|
||||
head: AtomicU64::new(0),
|
||||
tail: AtomicU64::new(0),
|
||||
dropped: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn claim(&self) -> Option<u64> {
|
||||
loop {
|
||||
let head = self.head.load(Ordering::Relaxed);
|
||||
let tail = self.tail.load(Ordering::Acquire);
|
||||
if head.wrapping_sub(tail) >= CAPACITY as u64 {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.head
|
||||
.compare_exchange_weak(
|
||||
head,
|
||||
head.wrapping_add(1),
|
||||
Ordering::Acquire,
|
||||
Ordering::Relaxed,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Some(head & Self::MASK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop(&self) -> Option<u64> {
|
||||
let tail = self.tail.load(Ordering::Relaxed);
|
||||
let head = self.head.load(Ordering::Acquire);
|
||||
if head == tail {
|
||||
None
|
||||
} else {
|
||||
Some(tail & Self::MASK)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release(&self) {
|
||||
let tail = self.tail.load(Ordering::Relaxed);
|
||||
self.tail.store(tail.wrapping_add(1), Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn dropped_count(&self) -> u64 {
|
||||
self.dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> u64 {
|
||||
let head = self.head.load(Ordering::Relaxed);
|
||||
let tail = self.tail.load(Ordering::Relaxed);
|
||||
head.wrapping_sub(tail)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_counters_for_test(&self, value: u64) {
|
||||
self.head.store(value, Ordering::Relaxed);
|
||||
self.tail.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_ring_pop_returns_null_len_is_zero() {
|
||||
let r: Ring<u32, 8> = Ring::new();
|
||||
assert_eq!(0, r.len());
|
||||
assert_eq!(None, r.pop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_producer_consumer_round_trip() {
|
||||
let mut r: Ring<u32, 4> = Ring::new();
|
||||
let i = r.claim().unwrap() as usize;
|
||||
r.slots[i] = 42;
|
||||
assert_eq!(1, r.len());
|
||||
let j = r.pop().unwrap() as usize;
|
||||
assert_eq!(42, r.slots[j]);
|
||||
r.release();
|
||||
assert_eq!(0, r.len());
|
||||
assert_eq!(None, r.pop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_to_capacity_then_drop() {
|
||||
let mut r: Ring<u32, 4> = Ring::new();
|
||||
for k in 0..4 {
|
||||
let i = r.claim().unwrap() as usize;
|
||||
r.slots[i] = k;
|
||||
}
|
||||
assert_eq!(4, r.len());
|
||||
assert_eq!(None, r.claim());
|
||||
assert_eq!(1, r.dropped_count());
|
||||
assert_eq!(None, r.claim());
|
||||
assert_eq!(2, r.dropped_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_vs_empty_distinction_head_tail_wrap() {
|
||||
let mut r: Ring<u32, 4> = Ring::new();
|
||||
for cycle in 0..10 {
|
||||
for n in 0..4 {
|
||||
let i = r.claim().unwrap() as usize;
|
||||
r.slots[i] = cycle * 100 + n;
|
||||
}
|
||||
assert_eq!(None, r.claim());
|
||||
for m in 0..4 {
|
||||
let j = r.pop().unwrap() as usize;
|
||||
assert_eq!(cycle * 100 + m, r.slots[j]);
|
||||
r.release();
|
||||
}
|
||||
assert_eq!(None, r.pop());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_order_across_wraparound() {
|
||||
let mut r: Ring<u32, 4> = Ring::new();
|
||||
for value in 0..3 {
|
||||
let idx = r.claim().unwrap() as usize;
|
||||
r.slots[idx] = value;
|
||||
}
|
||||
let idx = r.pop().unwrap() as usize;
|
||||
assert_eq!(0, r.slots[idx]);
|
||||
r.release();
|
||||
let idx = r.pop().unwrap() as usize;
|
||||
assert_eq!(1, r.slots[idx]);
|
||||
r.release();
|
||||
for value in 3..=5 {
|
||||
let idx = r.claim().unwrap() as usize;
|
||||
r.slots[idx] = value;
|
||||
}
|
||||
for expected in 2..=5 {
|
||||
let j = r.pop().unwrap() as usize;
|
||||
assert_eq!(expected, r.slots[j]);
|
||||
r.release();
|
||||
}
|
||||
assert_eq!(None, r.pop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u64_counter_wrap_behavior_is_mask_correct() {
|
||||
let mut r: Ring<u32, 4> = Ring::new();
|
||||
let near_max = u64::MAX - 2;
|
||||
r.set_counters_for_test(near_max);
|
||||
assert_eq!(None, r.pop());
|
||||
for k in 0..4 {
|
||||
let i = r.claim().unwrap() as usize;
|
||||
r.slots[i] = k;
|
||||
}
|
||||
assert_eq!(None, r.claim());
|
||||
for m in 0..4 {
|
||||
let j = r.pop().unwrap() as usize;
|
||||
assert_eq!(m, r.slots[j]);
|
||||
r.release();
|
||||
}
|
||||
assert_eq!(None, r.pop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_producers_via_simulated_cas_contention() {
|
||||
let r: Ring<u32, 8> = Ring::new();
|
||||
let mut seen = [false; 8];
|
||||
for _ in 0..8 {
|
||||
let idx = r.claim().unwrap() as usize;
|
||||
assert!(!seen[idx]);
|
||||
seen[idx] = true;
|
||||
}
|
||||
assert!(seen.iter().all(|value| *value));
|
||||
assert_eq!(None, r.claim());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_counter_survives_interleaved_pop() {
|
||||
let r: Ring<u32, 2> = Ring::new();
|
||||
assert!(r.claim().is_some());
|
||||
assert!(r.claim().is_some());
|
||||
assert_eq!(None, r.claim());
|
||||
assert_eq!(1, r.dropped_count());
|
||||
assert!(r.pop().is_some());
|
||||
r.release();
|
||||
assert!(r.claim().is_some());
|
||||
assert_eq!(None, r.claim());
|
||||
assert_eq!(2, r.dropped_count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const WM_MOUSEMOVE: u32 = 0x0200;
|
||||
pub const WM_LBUTTONDOWN: u32 = 0x0201;
|
||||
pub const WM_LBUTTONUP: u32 = 0x0202;
|
||||
pub const WM_RBUTTONDOWN: u32 = 0x0204;
|
||||
pub const WM_RBUTTONUP: u32 = 0x0205;
|
||||
pub const WM_MBUTTONDOWN: u32 = 0x0207;
|
||||
pub const WM_MBUTTONUP: u32 = 0x0208;
|
||||
pub const WM_MOUSEWHEEL: u32 = 0x020a;
|
||||
pub const WM_XBUTTONDOWN: u32 = 0x020b;
|
||||
pub const WM_XBUTTONUP: u32 = 0x020c;
|
||||
pub const WM_MOUSEHWHEEL: u32 = 0x020e;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Axis {
|
||||
Vertical,
|
||||
Horizontal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Button { down: bool, button: u8 },
|
||||
Move,
|
||||
Wheel { axis: Axis, delta: i16 },
|
||||
Ignored,
|
||||
}
|
||||
|
||||
pub fn classify(msg: u32, xbutton: u16, wheel_delta: i16) -> Action {
|
||||
match msg {
|
||||
WM_MOUSEMOVE => Action::Move,
|
||||
WM_LBUTTONDOWN => Action::Button {
|
||||
down: true,
|
||||
button: 0,
|
||||
},
|
||||
WM_LBUTTONUP => Action::Button {
|
||||
down: false,
|
||||
button: 0,
|
||||
},
|
||||
WM_RBUTTONDOWN => Action::Button {
|
||||
down: true,
|
||||
button: 2,
|
||||
},
|
||||
WM_RBUTTONUP => Action::Button {
|
||||
down: false,
|
||||
button: 2,
|
||||
},
|
||||
WM_MBUTTONDOWN => Action::Button {
|
||||
down: true,
|
||||
button: 1,
|
||||
},
|
||||
WM_MBUTTONUP => Action::Button {
|
||||
down: false,
|
||||
button: 1,
|
||||
},
|
||||
WM_XBUTTONDOWN => match xbutton {
|
||||
1 => Action::Button {
|
||||
down: true,
|
||||
button: 3,
|
||||
},
|
||||
2 => Action::Button {
|
||||
down: true,
|
||||
button: 4,
|
||||
},
|
||||
_ => Action::Ignored,
|
||||
},
|
||||
WM_XBUTTONUP => match xbutton {
|
||||
1 => Action::Button {
|
||||
down: false,
|
||||
button: 3,
|
||||
},
|
||||
2 => Action::Button {
|
||||
down: false,
|
||||
button: 4,
|
||||
},
|
||||
_ => Action::Ignored,
|
||||
},
|
||||
WM_MOUSEWHEEL => Action::Wheel {
|
||||
axis: Axis::Vertical,
|
||||
delta: wheel_delta,
|
||||
},
|
||||
WM_MOUSEHWHEEL => Action::Wheel {
|
||||
axis: Axis::Horizontal,
|
||||
delta: wheel_delta,
|
||||
},
|
||||
_ => Action::Ignored,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn primary_buttons_up_down_resolve_to_012() {
|
||||
assert_eq!(
|
||||
Action::Button {
|
||||
down: true,
|
||||
button: 0
|
||||
},
|
||||
classify(WM_LBUTTONDOWN, 0, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
Action::Button {
|
||||
down: false,
|
||||
button: 2
|
||||
},
|
||||
classify(WM_RBUTTONUP, 0, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
Action::Button {
|
||||
down: true,
|
||||
button: 1
|
||||
},
|
||||
classify(WM_MBUTTONDOWN, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xbutton_1_2_map_to_back_forward() {
|
||||
assert_eq!(
|
||||
Action::Button {
|
||||
down: true,
|
||||
button: 3
|
||||
},
|
||||
classify(WM_XBUTTONDOWN, 1, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
Action::Button {
|
||||
down: false,
|
||||
button: 4
|
||||
},
|
||||
classify(WM_XBUTTONUP, 2, 0)
|
||||
);
|
||||
assert_eq!(Action::Ignored, classify(WM_XBUTTONDOWN, 7, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_wheel_preserves_signed_delta() {
|
||||
assert_eq!(
|
||||
Action::Wheel {
|
||||
axis: Axis::Vertical,
|
||||
delta: 120,
|
||||
},
|
||||
classify(WM_MOUSEWHEEL, 0, 120)
|
||||
);
|
||||
assert_eq!(
|
||||
Action::Wheel {
|
||||
axis: Axis::Vertical,
|
||||
delta: -240,
|
||||
},
|
||||
classify(WM_MOUSEWHEEL, 0, -240)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_wheel_reports_horizontal_axis() {
|
||||
assert_eq!(
|
||||
Action::Wheel {
|
||||
axis: Axis::Horizontal,
|
||||
delta: 120,
|
||||
},
|
||||
classify(WM_MOUSEHWHEEL, 0, 120)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_move_and_unknown_messages_distinguish_move_vs_ignored() {
|
||||
assert_eq!(Action::Move, classify(WM_MOUSEMOVE, 0, 0));
|
||||
assert_eq!(Action::Ignored, classify(0xdead, 0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct XkbLookup {
|
||||
pub group: u32,
|
||||
pub level: u32,
|
||||
}
|
||||
|
||||
pub fn xkb_lookup_for_base() -> XkbLookup {
|
||||
XkbLookup { group: 0, level: 0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xkb_lookup_for_base_pins_group_and_level_to_unshifted_base_keysym() {
|
||||
let lookup = xkb_lookup_for_base();
|
||||
assert_eq!(0, lookup.group);
|
||||
assert_eq!(0, lookup.level);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod audio;
|
||||
pub mod dbus;
|
||||
pub mod input;
|
||||
pub mod linux_audio;
|
||||
pub mod linux_evdev;
|
||||
pub mod linux_portals;
|
||||
pub mod mac_app_audio;
|
||||
pub mod mac_sysctl;
|
||||
pub mod platform_info;
|
||||
pub mod system_hunspell;
|
||||
pub mod voice;
|
||||
pub mod win_process_loopback;
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod routing;
|
||||
@@ -0,0 +1,689 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
pub type PropMap = BTreeMap<String, String>;
|
||||
pub type PropPattern = PropMap;
|
||||
|
||||
pub const MEDIA_CLASS_PLAYBACK_STREAM: &str = "Stream/Output/Audio";
|
||||
|
||||
#[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 pid = pid.into();
|
||||
if !pid.is_empty() {
|
||||
self.pids.insert(pid);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_binary(&mut self, name: impl Into<String>) {
|
||||
let name = name.into();
|
||||
if !name.is_empty() {
|
||||
self.binaries.insert(name);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_display_name(&mut self, name: impl Into<String>) {
|
||||
let name = name.into();
|
||||
if !name.is_empty() {
|
||||
self.display_names.insert(name);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_display_prefix(&mut self, prefix: impl Into<String>) {
|
||||
let prefix = prefix.into();
|
||||
if !prefix.is_empty() {
|
||||
self.display_prefixes.push(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(&self, properties: &PropMap) -> bool {
|
||||
properties
|
||||
.get("application.process.id")
|
||||
.is_some_and(|raw| self.pids.contains(raw))
|
||||
|| properties
|
||||
.get("pipewire.sec.pid")
|
||||
.is_some_and(|raw| self.pids.contains(raw))
|
||||
|| properties
|
||||
.get("application.process.binary")
|
||||
.is_some_and(|raw| contains_case_insensitive(&self.binaries, raw))
|
||||
|| [
|
||||
"application.name",
|
||||
"node.name",
|
||||
"node.nick",
|
||||
"node.description",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| {
|
||||
properties
|
||||
.get(*key)
|
||||
.is_some_and(|raw| self.matches_display_identity(raw))
|
||||
})
|
||||
}
|
||||
|
||||
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_pattern: &PropPattern) -> bool {
|
||||
expected_pattern
|
||||
.iter()
|
||||
.all(|(key, expected)| candidate.get(key).is_some_and(|actual| actual == expected))
|
||||
}
|
||||
|
||||
pub fn matches_any(candidate: &PropMap, patterns: &[PropPattern]) -> bool {
|
||||
patterns.iter().any(|item| matches_pattern(candidate, item))
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if properties.get("media.class").map(String::as_str) != Some(MEDIA_CLASS_PLAYBACK_STREAM) {
|
||||
return false;
|
||||
}
|
||||
if !rule.include_when.is_empty() {
|
||||
return matches_any(properties, &rule.include_when);
|
||||
}
|
||||
if rule.only_audio_sinks {
|
||||
if rule.only_default_audio_sink
|
||||
&& !targets_default_sink(properties, default_sink_name, default_sink_target_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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 mut idx = 0;
|
||||
while let Some(name_pos) = blob[idx..].find("\"name\"") {
|
||||
idx += name_pos + "\"name\"".len();
|
||||
let rest = blob[idx..].trim_start();
|
||||
let Some(after_colon) = rest.strip_prefix(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = after_colon.trim_start();
|
||||
let Some(mut value) = value.strip_prefix('"') else {
|
||||
return String::new();
|
||||
};
|
||||
let mut out = String::new();
|
||||
while let Some(ch) = value.chars().next() {
|
||||
value = &value[ch.len_utf8()..];
|
||||
match ch {
|
||||
'"' => return out,
|
||||
'\\' => {
|
||||
if let Some(escaped) = value.chars().next() {
|
||||
value = &value[escaped.len_utf8()..];
|
||||
out.push(escaped);
|
||||
}
|
||||
}
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
pub fn map(entries: &[(&str, &str)]) -> PropMap {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn system_rule() -> RoutingRule {
|
||||
RoutingRule {
|
||||
skip_hardware_devices: true,
|
||||
only_audio_sinks: true,
|
||||
only_default_audio_sink: true,
|
||||
..RoutingRule::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_pattern_matches_any_candidate() {
|
||||
let candidate = map(&[("application.name", "Example")]);
|
||||
let empty = PropPattern::new();
|
||||
assert!(matches_pattern(&candidate, &empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keys_and_mismatched_values_do_not_match() {
|
||||
let candidate = map(&[("application.name", "Example")]);
|
||||
let missing = map(&[("application.process.id", "1234")]);
|
||||
let mismatched = map(&[("application.name", "Other")]);
|
||||
assert!(!matches_pattern(&candidate, &missing));
|
||||
assert!(!matches_pattern(&candidate, &mismatched));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_any_requires_at_least_one_matching_pattern() {
|
||||
let candidate = map(&[("application.name", "Example")]);
|
||||
let patterns = vec![
|
||||
map(&[("application.name", "Other")]),
|
||||
map(&[("application.name", "Example")]),
|
||||
];
|
||||
assert!(matches_any(&candidate, &patterns));
|
||||
assert!(!matches_any(&candidate, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_mode_routes_only_default_playback_streams() {
|
||||
let self_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 = map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("target.object", analog),
|
||||
]);
|
||||
let other = map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("target.object", hdmi),
|
||||
]);
|
||||
let rule = system_rule();
|
||||
assert!(should_route_node(
|
||||
100,
|
||||
&stream,
|
||||
&rule,
|
||||
analog,
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
101,
|
||||
&other,
|
||||
&rule,
|
||||
analog,
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_self_identity_wins_over_include_rules() {
|
||||
let mut self_identity = SelfIdentity::default();
|
||||
self_identity.add_pid("4242");
|
||||
self_identity.add_binary("fluxer");
|
||||
let rule = RoutingRule {
|
||||
include_when: vec![map(&[("application.process.id", "4242")])],
|
||||
..RoutingRule::default()
|
||||
};
|
||||
let by_pid = map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("application.process.id", "4242"),
|
||||
]);
|
||||
let by_binary = map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("application.process.binary", "fluxer"),
|
||||
]);
|
||||
assert!(!should_route_node(
|
||||
200,
|
||||
&by_pid,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
201,
|
||||
&by_binary,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_refuses_non_playback_media_classes_even_when_included() {
|
||||
let self_identity = SelfIdentity::default();
|
||||
let rule = RoutingRule {
|
||||
include_when: vec![map(&[("application.name", "Recorder")])],
|
||||
..RoutingRule::default()
|
||||
};
|
||||
let input_stream = map(&[
|
||||
("media.class", "Stream/Input/Audio"),
|
||||
("application.name", "Recorder"),
|
||||
]);
|
||||
let device = map(&[
|
||||
("media.class", "Audio/Source"),
|
||||
("application.name", "Recorder"),
|
||||
]);
|
||||
assert!(!should_route_node(
|
||||
300,
|
||||
&input_stream,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
301,
|
||||
&device,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_mode_accepts_untargeted_and_node_target_streams() {
|
||||
let self_identity = SelfIdentity::default();
|
||||
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
|
||||
let untargeted = map(&[("media.class", MEDIA_CLASS_PLAYBACK_STREAM)]);
|
||||
let deprecated = map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("node.target", analog),
|
||||
]);
|
||||
let rule = system_rule();
|
||||
assert!(should_route_node(
|
||||
100,
|
||||
&untargeted,
|
||||
&rule,
|
||||
analog,
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(should_route_node(
|
||||
101,
|
||||
&deprecated,
|
||||
&rule,
|
||||
analog,
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_mode_accepts_default_sink_object_id_targets() {
|
||||
let self_identity = SelfIdentity::default();
|
||||
let analog = "alsa_output.pci-0000_00_1f.3.analog-stereo";
|
||||
let by_name = map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("target.object", analog),
|
||||
]);
|
||||
let by_id = map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("target.object", "42"),
|
||||
]);
|
||||
let other = 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,
|
||||
&self_identity
|
||||
));
|
||||
assert!(should_route_node(
|
||||
101,
|
||||
&by_id,
|
||||
&rule,
|
||||
analog,
|
||||
"42",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
102,
|
||||
&other,
|
||||
&rule,
|
||||
analog,
|
||||
"42",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_mode_honors_hardware_filtering_and_never_rules() {
|
||||
let self_identity = SelfIdentity::default();
|
||||
let rule = RoutingRule {
|
||||
include_when: vec![map(&[("application.name", "Firefox")])],
|
||||
never_when: vec![map(&[("application.process.id", "999")])],
|
||||
skip_hardware_devices: true,
|
||||
..RoutingRule::default()
|
||||
};
|
||||
let app = map(&[
|
||||
("application.name", "Firefox"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
let hardware = map(&[("application.name", "Firefox"), ("device.id", "5")]);
|
||||
let blocked = map(&[
|
||||
("application.name", "Firefox"),
|
||||
("application.process.id", "999"),
|
||||
]);
|
||||
assert!(should_route_node(
|
||||
10,
|
||||
&app,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
11,
|
||||
&hardware,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
12,
|
||||
&blocked,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_mode_rejects_non_playback_nodes_that_match_include_filter() {
|
||||
let self_identity = SelfIdentity::default();
|
||||
let rule = RoutingRule {
|
||||
include_when: vec![map(&[("application.name", "Chromium")])],
|
||||
..RoutingRule::default()
|
||||
};
|
||||
let rejected = [
|
||||
map(&[
|
||||
("application.name", "Chromium"),
|
||||
("media.class", "Audio/Source"),
|
||||
]),
|
||||
map(&[
|
||||
("application.name", "Chromium"),
|
||||
("media.class", "Audio/Sink"),
|
||||
]),
|
||||
map(&[
|
||||
("application.name", "Chromium"),
|
||||
("media.class", "Stream/Input/Audio"),
|
||||
]),
|
||||
map(&[
|
||||
("application.name", "Chromium"),
|
||||
("media.class", "Audio/Source/Virtual"),
|
||||
]),
|
||||
map(&[("application.name", "Chromium")]),
|
||||
];
|
||||
for (idx, props) in rejected.iter().enumerate() {
|
||||
assert!(!should_route_node(
|
||||
20 + idx as u32,
|
||||
props,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
let playback = map(&[
|
||||
("application.name", "Chromium"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
assert!(should_route_node(
|
||||
25,
|
||||
&playback,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rules_route_nothing_and_sink_id_is_excluded() {
|
||||
let self_identity = SelfIdentity::default();
|
||||
let app = map(&[
|
||||
("application.name", "Foo"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
assert!(!should_route_node(
|
||||
1,
|
||||
&app,
|
||||
&RoutingRule::default(),
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
7,
|
||||
&app,
|
||||
&system_rule(),
|
||||
"",
|
||||
"",
|
||||
7,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_default_sink_name_is_strict_and_tolerant() {
|
||||
assert_eq!(
|
||||
"alsa_output.foo",
|
||||
parse_default_sink_name("{\"name\":\"alsa_output.foo\",\"other\":\"bar\"}")
|
||||
);
|
||||
assert_eq!("", parse_default_sink_name("not-json"));
|
||||
assert_eq!("", parse_default_sink_name("{\"name\":42}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_self_exclude_beats_include_rules_across_pid_binary_name_keys() {
|
||||
let mut self_identity = SelfIdentity::default();
|
||||
let pid = std::process::id().to_string();
|
||||
self_identity.add_pid(&pid);
|
||||
self_identity.add_binary("fluxer");
|
||||
self_identity.add_binary("fluxer.exe");
|
||||
self_identity.add_display_name("Fluxer Canary");
|
||||
self_identity.add_display_prefix("Fluxer ");
|
||||
|
||||
let rule = RoutingRule {
|
||||
include_when: vec![map(&[("application.process.binary", "firefox")])],
|
||||
..RoutingRule::default()
|
||||
};
|
||||
let by_pid = map(&[
|
||||
("application.process.id", &pid),
|
||||
("application.name", "fluxer"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
let by_binary = map(&[
|
||||
("application.process.id", "999999"),
|
||||
("application.process.binary", "fluxer"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
let by_app_name = map(&[
|
||||
("application.name", "fluxer"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
let by_node_name = map(&[
|
||||
("node.name", "fluxer"),
|
||||
("application.name", "Other"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
let by_node_description = map(&[
|
||||
("node.description", "Fluxer Direct Capture (pid 4242)"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
let unrelated = map(&[
|
||||
("application.name", "Firefox"),
|
||||
("application.process.id", "424242"),
|
||||
("application.process.binary", "firefox"),
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
]);
|
||||
assert!(!should_route_node(
|
||||
101,
|
||||
&by_pid,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
102,
|
||||
&by_binary,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
103,
|
||||
&by_app_name,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
104,
|
||||
&by_node_name,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
107,
|
||||
&by_node_description,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(should_route_node(
|
||||
105,
|
||||
&unrelated,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
assert!(!should_route_node(
|
||||
106,
|
||||
&by_pid,
|
||||
&system_rule(),
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_identity_matches_across_the_four_documented_pipewire_keys() {
|
||||
let mut self_identity = SelfIdentity::default();
|
||||
self_identity.add_pid("1234");
|
||||
self_identity.add_binary("fluxer");
|
||||
self_identity.add_display_name("Fluxer Canary");
|
||||
self_identity.add_display_prefix("Fluxer ");
|
||||
assert!(self_identity.matches(&map(&[("application.process.id", "1234")])));
|
||||
assert!(self_identity.matches(&map(&[("pipewire.sec.pid", "1234")])));
|
||||
assert!(self_identity.matches(&map(&[("application.process.binary", "fluxer")])));
|
||||
assert!(self_identity.matches(&map(&[("application.name", "fluxer")])));
|
||||
assert!(self_identity.matches(&map(&[("node.name", "fluxer")])));
|
||||
assert!(self_identity.matches(&map(&[("node.nick", "Fluxer Canary")])));
|
||||
assert!(self_identity.matches(&map(&[("node.description", "Fluxer app audio capture",)])));
|
||||
assert!(!self_identity.matches(&map(&[("application.process.id", "9999")])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const EV_KEY: u16 = 0x01;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InputEvent {
|
||||
pub time_sec: i64,
|
||||
pub time_usec: i64,
|
||||
pub event_type: u16,
|
||||
pub code: u16,
|
||||
pub value: i32,
|
||||
}
|
||||
|
||||
impl InputEvent {
|
||||
pub const BYTE_LEN: usize = 24;
|
||||
|
||||
pub fn from_ne_bytes(bytes: [u8; Self::BYTE_LEN]) -> Self {
|
||||
let mut time_sec = [0_u8; 8];
|
||||
let mut time_usec = [0_u8; 8];
|
||||
let mut event_type = [0_u8; 2];
|
||||
let mut code = [0_u8; 2];
|
||||
let mut value = [0_u8; 4];
|
||||
|
||||
time_sec.copy_from_slice(&bytes[0..8]);
|
||||
time_usec.copy_from_slice(&bytes[8..16]);
|
||||
event_type.copy_from_slice(&bytes[16..18]);
|
||||
code.copy_from_slice(&bytes[18..20]);
|
||||
value.copy_from_slice(&bytes[20..24]);
|
||||
|
||||
Self {
|
||||
time_sec: i64::from_ne_bytes(time_sec),
|
||||
time_usec: i64::from_ne_bytes(time_usec),
|
||||
event_type: u16::from_ne_bytes(event_type),
|
||||
code: u16::from_ne_bytes(code),
|
||||
value: i32::from_ne_bytes(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_input_event(bytes: &[u8]) -> Option<InputEvent> {
|
||||
let chunk: [u8; InputEvent::BYTE_LEN] = bytes.get(..InputEvent::BYTE_LEN)?.try_into().ok()?;
|
||||
Some(InputEvent::from_ne_bytes(chunk))
|
||||
}
|
||||
|
||||
pub fn parse_input_events(bytes: &[u8]) -> impl Iterator<Item = InputEvent> + '_ {
|
||||
bytes.chunks_exact(InputEvent::BYTE_LEN).map(|chunk| {
|
||||
let mut event = [0_u8; InputEvent::BYTE_LEN];
|
||||
event.copy_from_slice(chunk);
|
||||
InputEvent::from_ne_bytes(event)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn event_bytes(event: InputEvent) -> [u8; InputEvent::BYTE_LEN] {
|
||||
let mut out = [0_u8; InputEvent::BYTE_LEN];
|
||||
out[0..8].copy_from_slice(&event.time_sec.to_ne_bytes());
|
||||
out[8..16].copy_from_slice(&event.time_usec.to_ne_bytes());
|
||||
out[16..18].copy_from_slice(&event.event_type.to_ne_bytes());
|
||||
out[18..20].copy_from_slice(&event.code.to_ne_bytes());
|
||||
out[20..24].copy_from_slice(&event.value.to_ne_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_event_layout_matches_64_bit_linux_abi() {
|
||||
assert_eq!(24, std::mem::size_of::<InputEvent>());
|
||||
assert_eq!(0, std::mem::offset_of!(InputEvent, time_sec));
|
||||
assert_eq!(8, std::mem::offset_of!(InputEvent, time_usec));
|
||||
assert_eq!(16, std::mem::offset_of!(InputEvent, event_type));
|
||||
assert_eq!(18, std::mem::offset_of!(InputEvent, code));
|
||||
assert_eq!(20, std::mem::offset_of!(InputEvent, value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_synthetic_key_a_press() {
|
||||
let raw = event_bytes(InputEvent {
|
||||
time_sec: 123,
|
||||
time_usec: 456,
|
||||
event_type: EV_KEY,
|
||||
code: 30,
|
||||
value: 1,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
Some(InputEvent {
|
||||
time_sec: 123,
|
||||
time_usec: 456,
|
||||
event_type: EV_KEY,
|
||||
code: 30,
|
||||
value: 1,
|
||||
}),
|
||||
parse_input_event(&raw)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_back_to_back_events_and_ignores_trailing_partial_bytes() {
|
||||
let first = InputEvent {
|
||||
time_sec: 1,
|
||||
time_usec: 2,
|
||||
event_type: EV_KEY,
|
||||
code: 30,
|
||||
value: 1,
|
||||
};
|
||||
let second = InputEvent {
|
||||
time_sec: 3,
|
||||
time_usec: 4,
|
||||
event_type: EV_KEY,
|
||||
code: 30,
|
||||
value: 0,
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(&event_bytes(first));
|
||||
bytes.extend_from_slice(&event_bytes(second));
|
||||
bytes.extend_from_slice(&[0xaa, 0xbb]);
|
||||
|
||||
let events: Vec<_> = parse_input_events(&bytes).collect();
|
||||
assert_eq!(vec![first, second], events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::input::keymap::KeyMapU16;
|
||||
|
||||
pub const KEY_MAP: &[KeyMapU16] = &[
|
||||
KeyMapU16 {
|
||||
code: 1,
|
||||
name: "Escape",
|
||||
},
|
||||
KeyMapU16 { code: 2, name: "1" },
|
||||
KeyMapU16 { code: 3, name: "2" },
|
||||
KeyMapU16 { code: 4, name: "3" },
|
||||
KeyMapU16 { code: 5, name: "4" },
|
||||
KeyMapU16 { code: 6, name: "5" },
|
||||
KeyMapU16 { code: 7, name: "6" },
|
||||
KeyMapU16 { code: 8, name: "7" },
|
||||
KeyMapU16 { code: 9, name: "8" },
|
||||
KeyMapU16 {
|
||||
code: 10,
|
||||
name: "9",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 11,
|
||||
name: "0",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 12,
|
||||
name: "Minus",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 13,
|
||||
name: "Equal",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 14,
|
||||
name: "Backspace",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 15,
|
||||
name: "Tab",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 16,
|
||||
name: "Q",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 17,
|
||||
name: "W",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 18,
|
||||
name: "E",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 19,
|
||||
name: "R",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 20,
|
||||
name: "T",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 21,
|
||||
name: "Y",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 22,
|
||||
name: "U",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 23,
|
||||
name: "I",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 24,
|
||||
name: "O",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 25,
|
||||
name: "P",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 26,
|
||||
name: "BracketLeft",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 27,
|
||||
name: "BracketRight",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 28,
|
||||
name: "Enter",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 29,
|
||||
name: "ControlLeft",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 30,
|
||||
name: "A",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 31,
|
||||
name: "S",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 32,
|
||||
name: "D",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 33,
|
||||
name: "F",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 34,
|
||||
name: "G",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 35,
|
||||
name: "H",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 36,
|
||||
name: "J",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 37,
|
||||
name: "K",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 38,
|
||||
name: "L",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 39,
|
||||
name: "Semicolon",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 40,
|
||||
name: "Quote",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 41,
|
||||
name: "Backquote",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 42,
|
||||
name: "ShiftLeft",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 43,
|
||||
name: "Backslash",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 44,
|
||||
name: "Z",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 45,
|
||||
name: "X",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 46,
|
||||
name: "C",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 47,
|
||||
name: "V",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 48,
|
||||
name: "B",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 49,
|
||||
name: "N",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 50,
|
||||
name: "M",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 51,
|
||||
name: "Comma",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 52,
|
||||
name: "Period",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 53,
|
||||
name: "Slash",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 54,
|
||||
name: "ShiftRight",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 55,
|
||||
name: "NumpadMultiply",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 56,
|
||||
name: "AltLeft",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 57,
|
||||
name: "Space",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 58,
|
||||
name: "CapsLock",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 59,
|
||||
name: "F1",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 60,
|
||||
name: "F2",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 61,
|
||||
name: "F3",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 62,
|
||||
name: "F4",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 63,
|
||||
name: "F5",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 64,
|
||||
name: "F6",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 65,
|
||||
name: "F7",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 66,
|
||||
name: "F8",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 67,
|
||||
name: "F9",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 68,
|
||||
name: "F10",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 69,
|
||||
name: "NumLock",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 70,
|
||||
name: "ScrollLock",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 71,
|
||||
name: "Numpad7",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 72,
|
||||
name: "Numpad8",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 73,
|
||||
name: "Numpad9",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 74,
|
||||
name: "NumpadSubtract",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 75,
|
||||
name: "Numpad4",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 76,
|
||||
name: "Numpad5",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 77,
|
||||
name: "Numpad6",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 78,
|
||||
name: "NumpadAdd",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 79,
|
||||
name: "Numpad1",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 80,
|
||||
name: "Numpad2",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 81,
|
||||
name: "Numpad3",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 82,
|
||||
name: "Numpad0",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 83,
|
||||
name: "NumpadDecimal",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 86,
|
||||
name: "IntlBackslash",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 119,
|
||||
name: "Pause",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 87,
|
||||
name: "F11",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 88,
|
||||
name: "F12",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 89,
|
||||
name: "IntlRo",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 90,
|
||||
name: "Lang3",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 92,
|
||||
name: "Convert",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 93,
|
||||
name: "KanaMode",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 94,
|
||||
name: "NonConvert",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 96,
|
||||
name: "NumpadEnter",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 97,
|
||||
name: "ControlRight",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 98,
|
||||
name: "NumpadDivide",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 99,
|
||||
name: "PrintScreen",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 100,
|
||||
name: "AltRight",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 102,
|
||||
name: "Home",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 103,
|
||||
name: "ArrowUp",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 104,
|
||||
name: "PageUp",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 105,
|
||||
name: "ArrowLeft",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 106,
|
||||
name: "ArrowRight",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 107,
|
||||
name: "End",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 108,
|
||||
name: "ArrowDown",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 109,
|
||||
name: "PageDown",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 110,
|
||||
name: "Insert",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 111,
|
||||
name: "Delete",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 113,
|
||||
name: "AudioVolumeMute",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 114,
|
||||
name: "AudioVolumeDown",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 115,
|
||||
name: "AudioVolumeUp",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 116,
|
||||
name: "Power",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 117,
|
||||
name: "NumpadEqual",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 121,
|
||||
name: "NumpadComma",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 122,
|
||||
name: "Lang1",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 123,
|
||||
name: "Lang2",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 124,
|
||||
name: "IntlYen",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 125,
|
||||
name: "MetaLeft",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 126,
|
||||
name: "MetaRight",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 127,
|
||||
name: "ContextMenu",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 142,
|
||||
name: "Sleep",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 143,
|
||||
name: "WakeUp",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 148,
|
||||
name: "LaunchApp1",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 149,
|
||||
name: "LaunchApp2",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 155,
|
||||
name: "LaunchMail",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 158,
|
||||
name: "BrowserBack",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 159,
|
||||
name: "BrowserForward",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 163,
|
||||
name: "MediaTrackNext",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 164,
|
||||
name: "MediaPlayPause",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 165,
|
||||
name: "MediaTrackPrevious",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 166,
|
||||
name: "MediaStop",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 172,
|
||||
name: "BrowserHome",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 173,
|
||||
name: "BrowserRefresh",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 183,
|
||||
name: "F13",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 184,
|
||||
name: "F14",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 185,
|
||||
name: "F15",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 186,
|
||||
name: "F16",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 187,
|
||||
name: "F17",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 188,
|
||||
name: "F18",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 189,
|
||||
name: "F19",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 190,
|
||||
name: "F20",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 191,
|
||||
name: "F21",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 192,
|
||||
name: "F22",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 193,
|
||||
name: "F23",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 194,
|
||||
name: "F24",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 217,
|
||||
name: "BrowserSearch",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 226,
|
||||
name: "LaunchMediaPlayer",
|
||||
},
|
||||
KeyMapU16 {
|
||||
code: 364,
|
||||
name: "BrowserFavorites",
|
||||
},
|
||||
];
|
||||
|
||||
pub const LEFT_CTRL: u16 = 29;
|
||||
pub const RIGHT_CTRL: u16 = 97;
|
||||
pub const LEFT_SHIFT: u16 = 42;
|
||||
pub const RIGHT_SHIFT: u16 = 54;
|
||||
pub const LEFT_ALT: u16 = 56;
|
||||
pub const RIGHT_ALT: u16 = 100;
|
||||
pub const LEFT_META: u16 = 125;
|
||||
pub const RIGHT_META: u16 = 126;
|
||||
|
||||
pub const BTN_LEFT: u16 = 0x110;
|
||||
pub const BTN_RIGHT: u16 = 0x111;
|
||||
pub const BTN_MIDDLE: u16 = 0x112;
|
||||
pub const BTN_SIDE: u16 = 0x113;
|
||||
pub const BTN_EXTRA: u16 = 0x114;
|
||||
pub const BTN_FORWARD: u16 = 0x115;
|
||||
pub const BTN_BACK: u16 = 0x116;
|
||||
|
||||
pub fn evdev_button_to_browser_button(code: u16) -> Option<u8> {
|
||||
match code {
|
||||
BTN_LEFT => Some(0),
|
||||
BTN_MIDDLE => Some(1),
|
||||
BTN_RIGHT => Some(2),
|
||||
BTN_SIDE | BTN_BACK => Some(3),
|
||||
BTN_EXTRA | BTN_FORWARD => Some(4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keycode_to_name(code: u16) -> Option<&'static str> {
|
||||
KEY_MAP
|
||||
.iter()
|
||||
.find(|entry| entry.code == code)
|
||||
.map(|entry| entry.name)
|
||||
}
|
||||
|
||||
pub fn name_to_keycode(name: &str) -> u16 {
|
||||
KEY_MAP
|
||||
.iter()
|
||||
.find(|entry| entry.name == name)
|
||||
.map_or(0, |entry| entry.code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keycode_to_name_covers_canonical_letters_and_arrows() {
|
||||
assert_eq!(Some("A"), keycode_to_name(30));
|
||||
assert_eq!(Some("Z"), keycode_to_name(44));
|
||||
assert_eq!(Some("Pause"), keycode_to_name(119));
|
||||
assert_eq!(Some("F13"), keycode_to_name(183));
|
||||
assert_eq!(Some("NumpadEnter"), keycode_to_name(96));
|
||||
assert_eq!(Some("AudioVolumeMute"), keycode_to_name(113));
|
||||
assert_eq!(Some("ArrowUp"), keycode_to_name(103));
|
||||
assert_eq!(Some("MetaLeft"), keycode_to_name(125));
|
||||
assert_eq!(None, keycode_to_name(0));
|
||||
assert_eq!(None, keycode_to_name(0xffff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_to_keycode_round_trips_every_entry() {
|
||||
for entry in KEY_MAP {
|
||||
assert_eq!(entry.code, name_to_keycode(entry.name));
|
||||
}
|
||||
assert_eq!(0, name_to_keycode("NoSuchKey"));
|
||||
assert_eq!(0, name_to_keycode(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evdev_button_to_browser_button_matches_dom_convention() {
|
||||
assert_eq!(Some(0), evdev_button_to_browser_button(BTN_LEFT));
|
||||
assert_eq!(Some(1), evdev_button_to_browser_button(BTN_MIDDLE));
|
||||
assert_eq!(Some(2), evdev_button_to_browser_button(BTN_RIGHT));
|
||||
assert_eq!(Some(3), evdev_button_to_browser_button(BTN_SIDE));
|
||||
assert_eq!(Some(3), evdev_button_to_browser_button(BTN_BACK));
|
||||
assert_eq!(Some(4), evdev_button_to_browser_button(BTN_EXTRA));
|
||||
assert_eq!(Some(4), evdev_button_to_browser_button(BTN_FORWARD));
|
||||
assert_eq!(None, evdev_button_to_browser_button(0x100));
|
||||
assert_eq!(None, evdev_button_to_browser_button(0xffff));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod event;
|
||||
pub mod keymap;
|
||||
@@ -0,0 +1,232 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PortalEntry {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub preferred_trigger: String,
|
||||
}
|
||||
|
||||
impl PortalEntry {
|
||||
pub fn new(id: &str, description: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
description: description.to_owned(),
|
||||
preferred_trigger: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_trigger(id: &str, description: &str, preferred_trigger: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
description: description.to_owned(),
|
||||
preferred_trigger: preferred_trigger.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PortalBoundShortcut {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub trigger_description: String,
|
||||
}
|
||||
|
||||
impl PortalBoundShortcut {
|
||||
pub fn new(id: &str, description: &str, trigger_description: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
description: description.to_owned(),
|
||||
trigger_description: trigger_description.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BindReason {
|
||||
NoPersistedShortcuts,
|
||||
NewIdsAdded,
|
||||
}
|
||||
|
||||
impl BindReason {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoPersistedShortcuts => "no-persisted-shortcuts",
|
||||
Self::NewIdsAdded => "new-ids-added",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConfigureAction {
|
||||
Reuse(Vec<PortalBoundShortcut>),
|
||||
Bind(BindReason),
|
||||
}
|
||||
|
||||
pub fn decide(requested: &[PortalEntry], persisted: &[PortalBoundShortcut]) -> ConfigureAction {
|
||||
if persisted.is_empty() {
|
||||
return ConfigureAction::Bind(BindReason::NoPersistedShortcuts);
|
||||
}
|
||||
if requested
|
||||
.iter()
|
||||
.any(|entry| !has_persisted_shortcut(&entry.id, persisted))
|
||||
{
|
||||
return ConfigureAction::Bind(BindReason::NewIdsAdded);
|
||||
}
|
||||
ConfigureAction::Reuse(persisted.to_vec())
|
||||
}
|
||||
|
||||
pub fn has_persisted_shortcut(id: &str, persisted: &[PortalBoundShortcut]) -> bool {
|
||||
persisted.iter().any(|shortcut| shortcut.id == id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PathError {
|
||||
NoSpaceLeft,
|
||||
}
|
||||
|
||||
pub fn request_path(
|
||||
unique_bus_name: &str,
|
||||
handle_token: &str,
|
||||
capacity: usize,
|
||||
) -> Result<String, PathError> {
|
||||
let trimmed = unique_bus_name.strip_prefix(':').unwrap_or(unique_bus_name);
|
||||
let normalized = trimmed.replace('.', "_");
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{normalized}/{handle_token}");
|
||||
if path.len() > capacity {
|
||||
Err(PathError::NoSpaceLeft)
|
||||
} else {
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stable_key(entries: &[PortalEntry]) -> String {
|
||||
let mut sorted = entries.to_vec();
|
||||
sorted.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let mut out = String::from("[");
|
||||
for (index, entry) in sorted.iter().enumerate() {
|
||||
if index != 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("{id=");
|
||||
out.push_str(&entry.id);
|
||||
out.push_str(",desc=");
|
||||
out.push_str(&entry.description);
|
||||
out.push_str(",trig=");
|
||||
out.push_str(&entry.preferred_trigger);
|
||||
out.push('}');
|
||||
}
|
||||
out.push(']');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decide_empty_persisted_set_requests_first_time_bind() {
|
||||
let action = decide(&[PortalEntry::new("mute", "Toggle mute")], &[]);
|
||||
assert_eq!(
|
||||
ConfigureAction::Bind(BindReason::NoPersistedShortcuts),
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decide_matching_ids_on_restart_reuses_persisted_bindings() {
|
||||
let persisted = [
|
||||
PortalBoundShortcut::new("mute", "Toggle mute", "Ctrl+Shift+M"),
|
||||
PortalBoundShortcut::new("deafen", "Toggle deafen", "Ctrl+Shift+D"),
|
||||
];
|
||||
let requested = [
|
||||
PortalEntry::new("mute", "Toggle mute"),
|
||||
PortalEntry::new("deafen", "Toggle deafen"),
|
||||
];
|
||||
let action = decide(&requested, &persisted);
|
||||
assert!(matches!(action, ConfigureAction::Reuse(shortcuts) if shortcuts.len() == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decide_new_id_added_since_last_launch_triggers_fresh_bind() {
|
||||
let persisted = [PortalBoundShortcut::new(
|
||||
"mute",
|
||||
"Toggle mute",
|
||||
"Ctrl+Shift+M",
|
||||
)];
|
||||
let requested = [
|
||||
PortalEntry::new("mute", "Toggle mute"),
|
||||
PortalEntry::new("push_to_talk", "Push to talk"),
|
||||
];
|
||||
assert_eq!(
|
||||
ConfigureAction::Bind(BindReason::NewIdsAdded),
|
||||
decide(&requested, &persisted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decide_removing_previously_persisted_id_does_not_rebind() {
|
||||
let persisted = [
|
||||
PortalBoundShortcut::new("mute", "Toggle mute", "Ctrl+Shift+M"),
|
||||
PortalBoundShortcut::new("deafen", "Toggle deafen", "Ctrl+Shift+D"),
|
||||
];
|
||||
let requested = [PortalEntry::new("mute", "Toggle mute")];
|
||||
assert!(matches!(
|
||||
decide(&requested, &persisted),
|
||||
ConfigureAction::Reuse(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decide_empty_requested_with_empty_persisted_reports_no_persisted() {
|
||||
assert_eq!(
|
||||
ConfigureAction::Bind(BindReason::NoPersistedShortcuts),
|
||||
decide(&[], &[])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_path_well_known_shape_per_portal_spec() {
|
||||
assert_eq!(
|
||||
"/org/freedesktop/portal/desktop/request/1_42/fluxer_gs_create_xyz",
|
||||
request_path(":1.42", "fluxer_gs_create_xyz", 256).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_path_handles_unique_names_without_leading_colon() {
|
||||
assert_eq!(
|
||||
"/org/freedesktop/portal/desktop/request/1_0_7/tok",
|
||||
request_path("1.0.7", "tok", 256).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_path_rejects_too_small_buffer() {
|
||||
assert_eq!(
|
||||
Err(PathError::NoSpaceLeft),
|
||||
request_path(":1.42", "tok", 16)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_key_sorts_by_id_and_includes_description_and_trigger() {
|
||||
let key = stable_key(&[
|
||||
PortalEntry::new("mute", "Toggle mute"),
|
||||
PortalEntry::with_trigger("deafen", "Toggle deafen", "Ctrl+D"),
|
||||
]);
|
||||
assert_eq!(
|
||||
"[{id=deafen,desc=Toggle deafen,trig=Ctrl+D},{id=mute,desc=Toggle mute,trig=}]",
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_reason_name_matches_js_side_strings() {
|
||||
assert_eq!(
|
||||
"no-persisted-shortcuts",
|
||||
BindReason::NoPersistedShortcuts.name()
|
||||
);
|
||||
assert_eq!("new-ids-added", BindReason::NewIdsAdded.name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod decide;
|
||||
pub mod pid_payload;
|
||||
pub mod portal_snapshot;
|
||||
pub mod window_pid;
|
||||
pub mod x11_window_pid;
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub fn parse_shell_eval_pid_payload(payload: &str) -> Option<u32> {
|
||||
let bytes = payload.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if !bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
if let Some(pid) = payload[start..i]
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.filter(|pid| *pid != 0)
|
||||
{
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_plain_integer() {
|
||||
assert_eq!(Some(1234), parse_shell_eval_pid_payload("1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_integer_from_json_array_form() {
|
||||
assert_eq!(Some(4242), parse_shell_eval_pid_payload("[4242]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_zero_and_returns_next_positive() {
|
||||
assert_eq!(Some(17), parse_shell_eval_pid_payload("[0, 17]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_null_when_no_digits_present() {
|
||||
assert_eq!(None, parse_shell_eval_pid_payload("undefined"));
|
||||
assert_eq!(None, parse_shell_eval_pid_payload(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_values_that_overflow_u32() {
|
||||
assert_eq!(None, parse_shell_eval_pid_payload("4294967296"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::decide::{PortalBoundShortcut, PortalEntry, has_persisted_shortcut, stable_key};
|
||||
|
||||
static TOKEN_SEQ: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub fn mint_token(prefix: &str) -> String {
|
||||
let seq = TOKEN_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
format!("{prefix}_{ms:x}_{seq:x}")
|
||||
}
|
||||
|
||||
pub fn compute_stable_key(entries: &[PortalEntry]) -> String {
|
||||
stable_key(entries)
|
||||
}
|
||||
|
||||
pub fn merge_shortcut_snapshots(
|
||||
persisted: &[PortalBoundShortcut],
|
||||
bound: &[PortalBoundShortcut],
|
||||
) -> Vec<PortalBoundShortcut> {
|
||||
let mut out = persisted.to_vec();
|
||||
for shortcut in bound {
|
||||
if !has_persisted_shortcut(&shortcut.id, &out) {
|
||||
out.push(shortcut.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::linux_portals::decide::{PortalBoundShortcut, PortalEntry};
|
||||
|
||||
#[test]
|
||||
fn compute_stable_key_is_deterministic_across_orderings() {
|
||||
let a = compute_stable_key(&[
|
||||
PortalEntry::new("mute", "Toggle mute"),
|
||||
PortalEntry::new("deafen", "Toggle deafen"),
|
||||
]);
|
||||
let b = compute_stable_key(&[
|
||||
PortalEntry::new("deafen", "Toggle deafen"),
|
||||
PortalEntry::new("mute", "Toggle mute"),
|
||||
]);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_token_is_well_formed_and_monotonically_distinct_within_process() {
|
||||
let a = mint_token("fluxer_gs_create");
|
||||
let b = mint_token("fluxer_gs_create");
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with("fluxer_gs_create_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_shortcut_snapshots_preserves_existing_and_adds_newly_bound_ids() {
|
||||
let persisted = [PortalBoundShortcut::new(
|
||||
"mute",
|
||||
"Toggle mute",
|
||||
"Ctrl+Shift+M",
|
||||
)];
|
||||
let bound = [PortalBoundShortcut::new(
|
||||
"push_to_talk",
|
||||
"Push to talk",
|
||||
"Ctrl+Shift+Space",
|
||||
)];
|
||||
let merged = merge_shortcut_snapshots(&persisted, &bound);
|
||||
assert_eq!(2, merged.len());
|
||||
assert_eq!("mute", merged[0].id);
|
||||
assert_eq!("push_to_talk", merged[1].id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PathError {
|
||||
NoSpaceLeft,
|
||||
}
|
||||
|
||||
pub fn is_safe_kwin_path_segment(token: &str) -> bool {
|
||||
!token.is_empty()
|
||||
&& token
|
||||
.bytes()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
|
||||
}
|
||||
|
||||
pub fn build_kwin_window_path(token: &str, capacity: usize) -> Result<String, PathError> {
|
||||
let path = format!("/org/kde/KWin/Window/{token}");
|
||||
if path.len() + 1 > capacity {
|
||||
Err(PathError::NoSpaceLeft)
|
||||
} else {
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn safe_kwin_path_segment_accepts_plain_alnum_underscore() {
|
||||
assert!(is_safe_kwin_path_segment("abc"));
|
||||
assert!(is_safe_kwin_path_segment("123"));
|
||||
assert!(is_safe_kwin_path_segment("aZ_9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_kwin_path_segment_rejects_empty() {
|
||||
assert!(!is_safe_kwin_path_segment(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_kwin_path_segment_rejects_path_traversal_and_shell_metacharacters() {
|
||||
assert!(!is_safe_kwin_path_segment("../etc"));
|
||||
assert!(!is_safe_kwin_path_segment("a/b"));
|
||||
assert!(!is_safe_kwin_path_segment("$(rm -rf)"));
|
||||
assert!(!is_safe_kwin_path_segment("a;b"));
|
||||
assert!(!is_safe_kwin_path_segment("a-b"));
|
||||
assert!(!is_safe_kwin_path_segment("a.b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_kwin_window_path_shapes_path_correctly() {
|
||||
assert_eq!(
|
||||
"/org/kde/KWin/Window/abc123",
|
||||
build_kwin_window_path("abc123", 128).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_kwin_window_path_rejects_too_small_buffer() {
|
||||
assert_eq!(
|
||||
Err(PathError::NoSpaceLeft),
|
||||
build_kwin_window_path("abc", 8)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub type Window = u64;
|
||||
|
||||
pub fn parse_window_token(token: &str) -> Option<Window> {
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let parsed = if let Some(rest) = token
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| token.strip_prefix("0X"))
|
||||
{
|
||||
u64::from_str_radix(rest, 16).ok()?
|
||||
} else {
|
||||
token.parse::<u64>().ok()?
|
||||
};
|
||||
(parsed != 0).then_some(parsed)
|
||||
}
|
||||
|
||||
pub fn pid_from_long(value: i64) -> Option<u32> {
|
||||
u32::try_from(value).ok().filter(|pid| *pid > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_window_token_accepts_decimal_and_hexadecimal_xids() {
|
||||
assert_eq!(Some(123), parse_window_token("123"));
|
||||
assert_eq!(Some(0x3a00007), parse_window_token("0x3a00007"));
|
||||
assert_eq!(Some(0x3a00007), parse_window_token("0X3a00007"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_window_token_rejects_invalid_or_zero_xids() {
|
||||
assert_eq!(None, parse_window_token(""));
|
||||
assert_eq!(None, parse_window_token("0"));
|
||||
assert_eq!(None, parse_window_token("0x"));
|
||||
assert_eq!(None, parse_window_token("0xG"));
|
||||
assert_eq!(None, parse_window_token("../123"));
|
||||
assert_eq!(None, parse_window_token("123abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_from_long_validates_positive_uint32_process_ids() {
|
||||
assert_eq!(Some(1), pid_from_long(1));
|
||||
assert_eq!(Some(42_424), pid_from_long(42_424));
|
||||
assert_eq!(None, pid_from_long(0));
|
||||
assert_eq!(None, pid_from_long(-1));
|
||||
assert_eq!(None, pid_from_long(u32::MAX as i64 + 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AudioStreamBasicDescription {
|
||||
pub m_sample_rate: f64,
|
||||
pub m_format_id: u32,
|
||||
pub m_format_flags: u32,
|
||||
pub m_bytes_per_packet: u32,
|
||||
pub m_frames_per_packet: u32,
|
||||
pub m_bytes_per_frame: u32,
|
||||
pub m_channels_per_frame: u32,
|
||||
pub m_bits_per_channel: u32,
|
||||
pub m_reserved: u32,
|
||||
}
|
||||
|
||||
pub const K_AUDIO_FORMAT_LINEAR_PCM: u32 =
|
||||
(('l' as u32) << 24) | (('p' as u32) << 16) | (('c' as u32) << 8) | ('m' as u32);
|
||||
|
||||
pub const K_LINEAR_PCM_FORMAT_FLAG_IS_FLOAT: u32 = 1 << 0;
|
||||
pub const K_LINEAR_PCM_FORMAT_FLAG_IS_BIG_ENDIAN: u32 = 1 << 1;
|
||||
pub const K_LINEAR_PCM_FORMAT_FLAG_IS_SIGNED_INTEGER: u32 = 1 << 2;
|
||||
pub const K_LINEAR_PCM_FORMAT_FLAG_IS_PACKED: u32 = 1 << 3;
|
||||
pub const K_LINEAR_PCM_FORMAT_FLAG_IS_NON_INTERLEAVED: u32 = 1 << 5;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AudioBuffer {
|
||||
pub m_number_channels: u32,
|
||||
pub m_data_byte_size: u32,
|
||||
pub m_data: *const u8,
|
||||
}
|
||||
|
||||
impl AudioBuffer {
|
||||
pub fn from_slice<T>(channels: u32, slice: &[T]) -> Self {
|
||||
Self {
|
||||
m_number_channels: channels,
|
||||
m_data_byte_size: std::mem::size_of_val(slice) as u32,
|
||||
m_data: slice.as_ptr().cast(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AudioBufferListN<const N: usize> {
|
||||
pub m_number_buffers: u32,
|
||||
pub buffers: [AudioBuffer; N],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PcmConvertError {
|
||||
UnsupportedFormat,
|
||||
UnsupportedBitDepth,
|
||||
MissingData,
|
||||
OutputTooSmall,
|
||||
}
|
||||
|
||||
pub fn build_output_asbd(sample_rate: f64, channels: u32) -> AudioStreamBasicDescription {
|
||||
let bytes_per_frame = 4 * channels;
|
||||
AudioStreamBasicDescription {
|
||||
m_sample_rate: sample_rate,
|
||||
m_format_id: K_AUDIO_FORMAT_LINEAR_PCM,
|
||||
m_format_flags: K_LINEAR_PCM_FORMAT_FLAG_IS_FLOAT | K_LINEAR_PCM_FORMAT_FLAG_IS_PACKED,
|
||||
m_bytes_per_packet: bytes_per_frame,
|
||||
m_frames_per_packet: 1,
|
||||
m_bytes_per_frame: bytes_per_frame,
|
||||
m_channels_per_frame: channels,
|
||||
m_bits_per_channel: 32,
|
||||
m_reserved: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_input_asbd(
|
||||
sample_rate: f64,
|
||||
channels: u32,
|
||||
non_interleaved: bool,
|
||||
) -> AudioStreamBasicDescription {
|
||||
let bytes_per_frame = if non_interleaved { 4 } else { 4 * channels };
|
||||
let mut flags = K_LINEAR_PCM_FORMAT_FLAG_IS_FLOAT | K_LINEAR_PCM_FORMAT_FLAG_IS_PACKED;
|
||||
if non_interleaved {
|
||||
flags |= K_LINEAR_PCM_FORMAT_FLAG_IS_NON_INTERLEAVED;
|
||||
}
|
||||
AudioStreamBasicDescription {
|
||||
m_sample_rate: sample_rate,
|
||||
m_format_id: K_AUDIO_FORMAT_LINEAR_PCM,
|
||||
m_format_flags: flags,
|
||||
m_bytes_per_packet: bytes_per_frame,
|
||||
m_frames_per_packet: 1,
|
||||
m_bytes_per_frame: bytes_per_frame,
|
||||
m_channels_per_frame: channels,
|
||||
m_bits_per_channel: 32,
|
||||
m_reserved: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn output_frame_capacity(input_frames: u32, in_rate: f64, out_rate: f64) -> u32 {
|
||||
if in_rate <= 0.0 {
|
||||
return input_frames;
|
||||
}
|
||||
((input_frames as f64 * (out_rate / in_rate)).ceil() + 1.0) as u32
|
||||
}
|
||||
|
||||
pub fn converted_frame_count(input_frames: u32, in_rate: f64, out_rate: f64) -> u32 {
|
||||
if input_frames == 0 {
|
||||
return 0;
|
||||
}
|
||||
if in_rate <= 0.0 || out_rate <= 0.0 || in_rate == out_rate {
|
||||
return input_frames;
|
||||
}
|
||||
(input_frames as f64 * (out_rate / in_rate)).ceil() as u32
|
||||
}
|
||||
|
||||
pub fn is_linear_pcm(asbd: AudioStreamBasicDescription) -> bool {
|
||||
asbd.m_format_id == K_AUDIO_FORMAT_LINEAR_PCM
|
||||
}
|
||||
|
||||
pub fn is_float(asbd: AudioStreamBasicDescription) -> bool {
|
||||
(asbd.m_format_flags & K_LINEAR_PCM_FORMAT_FLAG_IS_FLOAT) != 0
|
||||
}
|
||||
|
||||
pub fn is_signed_integer(asbd: AudioStreamBasicDescription) -> bool {
|
||||
(asbd.m_format_flags & K_LINEAR_PCM_FORMAT_FLAG_IS_SIGNED_INTEGER) != 0
|
||||
}
|
||||
|
||||
pub fn is_big_endian(asbd: AudioStreamBasicDescription) -> bool {
|
||||
(asbd.m_format_flags & K_LINEAR_PCM_FORMAT_FLAG_IS_BIG_ENDIAN) != 0
|
||||
}
|
||||
|
||||
pub fn is_packed(asbd: AudioStreamBasicDescription) -> bool {
|
||||
(asbd.m_format_flags & K_LINEAR_PCM_FORMAT_FLAG_IS_PACKED) != 0
|
||||
}
|
||||
|
||||
pub fn is_non_interleaved(asbd: AudioStreamBasicDescription) -> bool {
|
||||
(asbd.m_format_flags & K_LINEAR_PCM_FORMAT_FLAG_IS_NON_INTERLEAVED) != 0
|
||||
}
|
||||
|
||||
pub fn is_native_f32_interleaved(asbd: AudioStreamBasicDescription) -> bool {
|
||||
is_linear_pcm(asbd)
|
||||
&& is_float(asbd)
|
||||
&& asbd.m_bits_per_channel == 32
|
||||
&& is_packed(asbd)
|
||||
&& !is_big_endian(asbd)
|
||||
&& !is_non_interleaved(asbd)
|
||||
}
|
||||
|
||||
pub fn is_native_f32_planar(asbd: AudioStreamBasicDescription) -> bool {
|
||||
is_linear_pcm(asbd)
|
||||
&& is_float(asbd)
|
||||
&& asbd.m_bits_per_channel == 32
|
||||
&& is_packed(asbd)
|
||||
&& !is_big_endian(asbd)
|
||||
&& is_non_interleaved(asbd)
|
||||
}
|
||||
|
||||
fn bytes_per_sample(asbd: AudioStreamBasicDescription) -> Result<u32, PcmConvertError> {
|
||||
if !is_linear_pcm(asbd) {
|
||||
return Err(PcmConvertError::UnsupportedFormat);
|
||||
}
|
||||
if asbd.m_bits_per_channel == 0 || !asbd.m_bits_per_channel.is_multiple_of(8) {
|
||||
return Err(PcmConvertError::UnsupportedBitDepth);
|
||||
}
|
||||
let bytes = asbd.m_bits_per_channel / 8;
|
||||
if matches!(bytes, 1 | 2 | 3 | 4 | 8) {
|
||||
Ok(bytes)
|
||||
} else {
|
||||
Err(PcmConvertError::UnsupportedBitDepth)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_frame_count_for_buffer_list<const N: usize>(
|
||||
asbd: AudioStreamBasicDescription,
|
||||
list: &AudioBufferListN<N>,
|
||||
) -> Result<u32, PcmConvertError> {
|
||||
if !is_linear_pcm(asbd) {
|
||||
return Err(PcmConvertError::UnsupportedFormat);
|
||||
}
|
||||
if list.m_number_buffers == 0 {
|
||||
return Err(PcmConvertError::MissingData);
|
||||
}
|
||||
let first = &list.buffers[0];
|
||||
if first.m_data.is_null() || first.m_data_byte_size == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let channels = if asbd.m_channels_per_frame == 0 {
|
||||
1
|
||||
} else {
|
||||
asbd.m_channels_per_frame
|
||||
};
|
||||
let bps = bytes_per_sample(asbd)?;
|
||||
let frame_stride = if asbd.m_bytes_per_frame != 0 {
|
||||
asbd.m_bytes_per_frame
|
||||
} else if is_non_interleaved(asbd) {
|
||||
bps
|
||||
} else {
|
||||
bps * channels
|
||||
};
|
||||
if frame_stride == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
Ok(first.m_data_byte_size / frame_stride)
|
||||
}
|
||||
|
||||
fn read_unsigned(bytes: &[u8], big_endian: bool) -> u64 {
|
||||
if big_endian {
|
||||
bytes
|
||||
.iter()
|
||||
.fold(0_u64, |out, byte| (out << 8) | *byte as u64)
|
||||
} else {
|
||||
bytes.iter().enumerate().fold(0_u64, |out, (index, byte)| {
|
||||
out | ((*byte as u64) << (index * 8))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_extend(value: u64, bits: u32) -> i64 {
|
||||
if bits == 64 {
|
||||
return value as i64;
|
||||
}
|
||||
let shift = 64 - bits;
|
||||
((value << shift) as i64) >> shift
|
||||
}
|
||||
|
||||
fn pow2_float(exponent: u32) -> f64 {
|
||||
2_f64.powi(exponent as i32)
|
||||
}
|
||||
|
||||
fn read_scalar_sample(
|
||||
asbd: AudioStreamBasicDescription,
|
||||
bytes: &[u8],
|
||||
) -> Result<f32, PcmConvertError> {
|
||||
let bits = asbd.m_bits_per_channel;
|
||||
if bits == 0 || bits > 64 || !bits.is_multiple_of(8) {
|
||||
return Err(PcmConvertError::UnsupportedBitDepth);
|
||||
}
|
||||
let raw = read_unsigned(bytes, is_big_endian(asbd));
|
||||
if is_float(asbd) {
|
||||
return match bits {
|
||||
32 => Ok(f32::from_bits(raw as u32)),
|
||||
64 => Ok(f64::from_bits(raw) as f32),
|
||||
_ => Err(PcmConvertError::UnsupportedBitDepth),
|
||||
};
|
||||
}
|
||||
if is_signed_integer(asbd) {
|
||||
let signed = sign_extend(raw, bits);
|
||||
return Ok((signed as f64 / pow2_float(bits - 1)) as f32);
|
||||
}
|
||||
let midpoint = pow2_float(bits - 1);
|
||||
Ok(((raw as f64 - midpoint) / midpoint) as f32)
|
||||
}
|
||||
|
||||
fn source_channel_for(target_channel: u32, source_channels: u32) -> u32 {
|
||||
if source_channels <= 1 {
|
||||
0
|
||||
} else {
|
||||
target_channel.min(source_channels - 1)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_frame_channel<const N: usize>(
|
||||
asbd: AudioStreamBasicDescription,
|
||||
list: &AudioBufferListN<N>,
|
||||
frame_index: u32,
|
||||
target_channel: u32,
|
||||
) -> Result<f32, PcmConvertError> {
|
||||
let channels = if asbd.m_channels_per_frame == 0 {
|
||||
1
|
||||
} else {
|
||||
asbd.m_channels_per_frame
|
||||
};
|
||||
let source_channel = source_channel_for(target_channel, channels);
|
||||
let bps = bytes_per_sample(asbd)?;
|
||||
let non_interleaved = is_non_interleaved(asbd);
|
||||
let buffer_count = (list.m_number_buffers as usize).min(N);
|
||||
if buffer_count == 0 {
|
||||
return Err(PcmConvertError::MissingData);
|
||||
}
|
||||
let buffer_index = if non_interleaved && buffer_count > 1 {
|
||||
(source_channel as usize).min(buffer_count - 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let buffer = &list.buffers[buffer_index];
|
||||
if buffer.m_data.is_null() {
|
||||
return Err(PcmConvertError::MissingData);
|
||||
}
|
||||
let data =
|
||||
unsafe { std::slice::from_raw_parts(buffer.m_data, buffer.m_data_byte_size as usize) };
|
||||
let frame_stride = if asbd.m_bytes_per_frame != 0 {
|
||||
asbd.m_bytes_per_frame
|
||||
} else if non_interleaved {
|
||||
bps
|
||||
} else {
|
||||
bps * channels
|
||||
};
|
||||
let channel_offset = if non_interleaved && buffer_count > 1 {
|
||||
0
|
||||
} else {
|
||||
source_channel * bps
|
||||
};
|
||||
let offset = frame_index as usize * frame_stride as usize + channel_offset as usize;
|
||||
let end = offset + bps as usize;
|
||||
if end > data.len() {
|
||||
return Err(PcmConvertError::MissingData);
|
||||
}
|
||||
read_scalar_sample(asbd, &data[offset..end])
|
||||
}
|
||||
|
||||
fn aligned_f32_slice(
|
||||
buffer: &AudioBuffer,
|
||||
samples: usize,
|
||||
) -> Result<Option<&[f32]>, PcmConvertError> {
|
||||
if buffer.m_data.is_null() {
|
||||
return Err(PcmConvertError::MissingData);
|
||||
}
|
||||
let bytes = samples * std::mem::size_of::<f32>();
|
||||
if (buffer.m_data_byte_size as usize) < bytes {
|
||||
return Err(PcmConvertError::MissingData);
|
||||
}
|
||||
let address = buffer.m_data as usize;
|
||||
if !address.is_multiple_of(std::mem::align_of::<f32>()) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(unsafe {
|
||||
std::slice::from_raw_parts(buffer.m_data.cast::<f32>(), samples)
|
||||
}))
|
||||
}
|
||||
|
||||
fn copy_native_interleaved_f32<const N: usize>(
|
||||
asbd: AudioStreamBasicDescription,
|
||||
list: &AudioBufferListN<N>,
|
||||
input_frames: u32,
|
||||
output_channels: u32,
|
||||
output: &mut [f32],
|
||||
) -> Result<Option<u32>, PcmConvertError> {
|
||||
let source_channels = asbd.m_channels_per_frame.max(1);
|
||||
let source_stride = source_channels * std::mem::size_of::<f32>() as u32;
|
||||
if asbd.m_bytes_per_frame != 0 && asbd.m_bytes_per_frame != source_stride {
|
||||
return Ok(None);
|
||||
}
|
||||
if list.m_number_buffers == 0 || N == 0 {
|
||||
return Err(PcmConvertError::MissingData);
|
||||
}
|
||||
let source_samples = input_frames as usize * source_channels as usize;
|
||||
let output_samples = input_frames as usize * output_channels as usize;
|
||||
let Some(source) = aligned_f32_slice(&list.buffers[0], source_samples)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
assert!(output_samples <= output.len());
|
||||
if source_channels == output_channels {
|
||||
output[..output_samples].copy_from_slice(&source[..output_samples]);
|
||||
return Ok(Some(input_frames));
|
||||
}
|
||||
for frame in 0..input_frames as usize {
|
||||
let source_base = frame * source_channels as usize;
|
||||
let output_base = frame * output_channels as usize;
|
||||
for channel in 0..output_channels as usize {
|
||||
let source_channel = source_channel_for(channel as u32, source_channels) as usize;
|
||||
output[output_base + channel] = source[source_base + source_channel];
|
||||
}
|
||||
}
|
||||
Ok(Some(input_frames))
|
||||
}
|
||||
|
||||
fn copy_native_planar_f32<const N: usize>(
|
||||
asbd: AudioStreamBasicDescription,
|
||||
list: &AudioBufferListN<N>,
|
||||
input_frames: u32,
|
||||
output_channels: u32,
|
||||
output: &mut [f32],
|
||||
) -> Result<Option<u32>, PcmConvertError> {
|
||||
if asbd.m_bytes_per_frame != 0 && asbd.m_bytes_per_frame != std::mem::size_of::<f32>() as u32 {
|
||||
return Ok(None);
|
||||
}
|
||||
let source_channels = asbd.m_channels_per_frame.max(1);
|
||||
let buffer_count = (list.m_number_buffers as usize).min(N);
|
||||
if buffer_count == 0 {
|
||||
return Err(PcmConvertError::MissingData);
|
||||
}
|
||||
let output_samples = input_frames as usize * output_channels as usize;
|
||||
assert!(output_samples <= output.len());
|
||||
for channel in 0..output_channels as usize {
|
||||
let source_channel = source_channel_for(channel as u32, source_channels) as usize;
|
||||
let buffer_index = if buffer_count > 1 {
|
||||
source_channel.min(buffer_count - 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let Some(source) = aligned_f32_slice(&list.buffers[buffer_index], input_frames as usize)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
for frame in 0..input_frames as usize {
|
||||
output[frame * output_channels as usize + channel] = source[frame];
|
||||
}
|
||||
}
|
||||
Ok(Some(input_frames))
|
||||
}
|
||||
|
||||
fn copy_native_f32<const N: usize>(
|
||||
asbd: AudioStreamBasicDescription,
|
||||
list: &AudioBufferListN<N>,
|
||||
input_frames: u32,
|
||||
output_channels: u32,
|
||||
output: &mut [f32],
|
||||
) -> Result<Option<u32>, PcmConvertError> {
|
||||
if is_native_f32_interleaved(asbd) {
|
||||
return copy_native_interleaved_f32(asbd, list, input_frames, output_channels, output);
|
||||
}
|
||||
if is_native_f32_planar(asbd) {
|
||||
return copy_native_planar_f32(asbd, list, input_frames, output_channels, output);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn convert_buffer_list_to_interleaved_f32<const N: usize>(
|
||||
asbd: AudioStreamBasicDescription,
|
||||
list: &AudioBufferListN<N>,
|
||||
input_frames: u32,
|
||||
output_sample_rate: f64,
|
||||
output_channels: u32,
|
||||
output: &mut [f32],
|
||||
) -> Result<u32, PcmConvertError> {
|
||||
if !is_linear_pcm(asbd) {
|
||||
return Err(PcmConvertError::UnsupportedFormat);
|
||||
}
|
||||
if output_channels == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let out_frames = converted_frame_count(input_frames, asbd.m_sample_rate, output_sample_rate);
|
||||
let needed = out_frames as usize * output_channels as usize;
|
||||
if needed > output.len() {
|
||||
return Err(PcmConvertError::OutputTooSmall);
|
||||
}
|
||||
if input_frames == 0 || out_frames == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let native_frames = if asbd.m_sample_rate == output_sample_rate {
|
||||
copy_native_f32(asbd, list, input_frames, output_channels, output)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(frames) = native_frames {
|
||||
return Ok(frames);
|
||||
}
|
||||
let step = if asbd.m_sample_rate > 0.0 && output_sample_rate > 0.0 {
|
||||
asbd.m_sample_rate / output_sample_rate
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for out_frame in 0..out_frames {
|
||||
let src_pos = out_frame as f64 * step;
|
||||
let mut base = src_pos.floor() as u32;
|
||||
if base >= input_frames {
|
||||
base = input_frames - 1;
|
||||
}
|
||||
let next = if base + 1 < input_frames {
|
||||
base + 1
|
||||
} else {
|
||||
base
|
||||
};
|
||||
let frac = (src_pos - src_pos.floor()) as f32;
|
||||
for ch in 0..output_channels {
|
||||
let a = read_frame_channel(asbd, list, base, ch)?;
|
||||
let b = read_frame_channel(asbd, list, next, ch)?;
|
||||
output[out_frame as usize * output_channels as usize + ch as usize] =
|
||||
a + (b - a) * frac;
|
||||
}
|
||||
}
|
||||
Ok(out_frames)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::mem::{offset_of, size_of};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn audio_stream_basic_description_field_offsets_match_apple_abi() {
|
||||
assert_eq!(0, offset_of!(AudioStreamBasicDescription, m_sample_rate));
|
||||
assert_eq!(8, offset_of!(AudioStreamBasicDescription, m_format_id));
|
||||
assert_eq!(12, offset_of!(AudioStreamBasicDescription, m_format_flags));
|
||||
assert_eq!(
|
||||
16,
|
||||
offset_of!(AudioStreamBasicDescription, m_bytes_per_packet)
|
||||
);
|
||||
assert_eq!(
|
||||
20,
|
||||
offset_of!(AudioStreamBasicDescription, m_frames_per_packet)
|
||||
);
|
||||
assert_eq!(
|
||||
24,
|
||||
offset_of!(AudioStreamBasicDescription, m_bytes_per_frame)
|
||||
);
|
||||
assert_eq!(
|
||||
28,
|
||||
offset_of!(AudioStreamBasicDescription, m_channels_per_frame)
|
||||
);
|
||||
assert_eq!(
|
||||
32,
|
||||
offset_of!(AudioStreamBasicDescription, m_bits_per_channel)
|
||||
);
|
||||
assert_eq!(36, offset_of!(AudioStreamBasicDescription, m_reserved));
|
||||
assert_eq!(40, size_of::<AudioStreamBasicDescription>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_offsets() {
|
||||
assert_eq!(0, offset_of!(AudioBuffer, m_number_channels));
|
||||
assert_eq!(4, offset_of!(AudioBuffer, m_data_byte_size));
|
||||
assert_eq!(8, offset_of!(AudioBuffer, m_data));
|
||||
assert_eq!(16, size_of::<AudioBuffer>());
|
||||
assert_eq!(0, offset_of!(AudioBufferListN<1>, m_number_buffers));
|
||||
assert_eq!(8, offset_of!(AudioBufferListN<1>, buffers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_format_linear_pcm_encodes_lpcm() {
|
||||
assert_eq!(0x6c70636d, K_AUDIO_FORMAT_LINEAR_PCM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_output_asbd_stereo_48k_interleaved_float32() {
|
||||
let a = build_output_asbd(48_000.0, 2);
|
||||
assert_eq!(48_000.0, a.m_sample_rate);
|
||||
assert_eq!(K_AUDIO_FORMAT_LINEAR_PCM, a.m_format_id);
|
||||
assert_eq!(
|
||||
K_LINEAR_PCM_FORMAT_FLAG_IS_FLOAT | K_LINEAR_PCM_FORMAT_FLAG_IS_PACKED,
|
||||
a.m_format_flags
|
||||
);
|
||||
assert_eq!(8, a.m_bytes_per_packet);
|
||||
assert_eq!(1, a.m_frames_per_packet);
|
||||
assert_eq!(8, a.m_bytes_per_frame);
|
||||
assert_eq!(2, a.m_channels_per_frame);
|
||||
assert_eq!(32, a.m_bits_per_channel);
|
||||
assert_eq!(0, a.m_reserved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_output_asbd_mono_441k_interleaved_float32() {
|
||||
let a = build_output_asbd(44_100.0, 1);
|
||||
assert_eq!(44_100.0, a.m_sample_rate);
|
||||
assert_eq!(4, a.m_bytes_per_packet);
|
||||
assert_eq!(4, a.m_bytes_per_frame);
|
||||
assert_eq!(1, a.m_channels_per_frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_input_asbd_non_interleaved_sets_stride_to_4_bytes_per_channel_plane() {
|
||||
let a = build_input_asbd(48_000.0, 2, true);
|
||||
assert_ne!(
|
||||
0,
|
||||
a.m_format_flags & K_LINEAR_PCM_FORMAT_FLAG_IS_NON_INTERLEAVED
|
||||
);
|
||||
assert_eq!(4, a.m_bytes_per_frame);
|
||||
assert_eq!(4, a.m_bytes_per_packet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_input_asbd_interleaved_omits_non_interleaved_flag() {
|
||||
let a = build_input_asbd(48_000.0, 2, false);
|
||||
assert_eq!(
|
||||
0,
|
||||
a.m_format_flags & K_LINEAR_PCM_FORMAT_FLAG_IS_NON_INTERLEAVED
|
||||
);
|
||||
assert_eq!(8, a.m_bytes_per_frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_f32_predicates_require_packed_little_endian_layout() {
|
||||
let interleaved = build_input_asbd(48_000.0, 2, false);
|
||||
let planar = build_input_asbd(48_000.0, 2, true);
|
||||
assert!(is_native_f32_interleaved(interleaved));
|
||||
assert!(is_native_f32_planar(planar));
|
||||
let mut padded = interleaved;
|
||||
padded.m_format_flags &= !K_LINEAR_PCM_FORMAT_FLAG_IS_PACKED;
|
||||
assert!(!is_native_f32_interleaved(padded));
|
||||
let mut big_endian = planar;
|
||||
big_endian.m_format_flags |= K_LINEAR_PCM_FORMAT_FLAG_IS_BIG_ENDIAN;
|
||||
assert!(!is_native_f32_planar(big_endian));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_frame_capacity_rounds_up_across_rate_ratios() {
|
||||
assert_eq!(1025, output_frame_capacity(1024, 48_000.0, 48_000.0));
|
||||
assert_eq!(1116, output_frame_capacity(1024, 44_100.0, 48_000.0));
|
||||
assert_eq!(512, output_frame_capacity(512, 0.0, 48_000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_frame_count_computes_exact_output_length_without_safety_padding() {
|
||||
assert_eq!(1024, converted_frame_count(1024, 48_000.0, 48_000.0));
|
||||
assert_eq!(1115, converted_frame_count(1024, 44_100.0, 48_000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_buffer_list_to_interleaved_f32_duplicates_mono_float32() {
|
||||
let samples = [0.25_f32, -0.5, 1.0];
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(1, &samples)],
|
||||
};
|
||||
let asbd = build_input_asbd(48_000.0, 1, false);
|
||||
let mut out = [0.0_f32; 6];
|
||||
let frames =
|
||||
convert_buffer_list_to_interleaved_f32(asbd, &list, 3, 48_000.0, 2, &mut out).unwrap();
|
||||
assert_eq!(3, frames);
|
||||
assert_eq!([0.25, 0.25, -0.5, -0.5, 1.0, 1.0], out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_buffer_list_to_interleaved_f32_copies_native_stereo_float32() {
|
||||
let samples = [0.25_f32, -0.5, 1.0, -1.0, 0.125, -0.125];
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(2, &samples)],
|
||||
};
|
||||
let asbd = build_input_asbd(48_000.0, 2, false);
|
||||
let mut out = [0.0_f32; 6];
|
||||
let frames =
|
||||
convert_buffer_list_to_interleaved_f32(asbd, &list, 3, 48_000.0, 2, &mut out).unwrap();
|
||||
assert_eq!(3, frames);
|
||||
assert_eq!(samples, out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_buffer_list_to_interleaved_f32_interleaves_planar_stereo_float32() {
|
||||
let left = [0.1_f32, 0.2, 0.3];
|
||||
let right = [-0.1_f32, -0.2, -0.3];
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 2,
|
||||
buffers: [
|
||||
AudioBuffer::from_slice(1, &left),
|
||||
AudioBuffer::from_slice(1, &right),
|
||||
],
|
||||
};
|
||||
let asbd = build_input_asbd(48_000.0, 2, true);
|
||||
let mut out = [0.0_f32; 6];
|
||||
let frames =
|
||||
convert_buffer_list_to_interleaved_f32(asbd, &list, 3, 48_000.0, 2, &mut out).unwrap();
|
||||
assert_eq!(3, frames);
|
||||
assert_eq!([0.1, -0.1, 0.2, -0.2, 0.3, -0.3], out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_frame_count_for_buffer_list_handles_interleaved_and_planar_input() {
|
||||
let interleaved = [0.1_f32, -0.1, 0.2, -0.2, 0.3, -0.3];
|
||||
let interleaved_list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(2, &interleaved)],
|
||||
};
|
||||
assert_eq!(
|
||||
3,
|
||||
input_frame_count_for_buffer_list(
|
||||
build_input_asbd(48_000.0, 2, false),
|
||||
&interleaved_list
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let left = [0.1_f32, 0.2, 0.3, 0.4];
|
||||
let right = [-0.1_f32, -0.2, -0.3, -0.4];
|
||||
let planar_list = AudioBufferListN {
|
||||
m_number_buffers: 2,
|
||||
buffers: [
|
||||
AudioBuffer::from_slice(1, &left),
|
||||
AudioBuffer::from_slice(1, &right),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
4,
|
||||
input_frame_count_for_buffer_list(build_input_asbd(48_000.0, 2, true), &planar_list)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_buffer_list_to_interleaved_f32_normalizes_signed_int16() {
|
||||
let samples = [0_i16, 16_384, -32_768, 32_767];
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(2, &samples)],
|
||||
};
|
||||
let mut asbd = build_input_asbd(48_000.0, 2, false);
|
||||
asbd.m_format_flags =
|
||||
K_LINEAR_PCM_FORMAT_FLAG_IS_SIGNED_INTEGER | K_LINEAR_PCM_FORMAT_FLAG_IS_PACKED;
|
||||
asbd.m_bytes_per_packet = 4;
|
||||
asbd.m_bytes_per_frame = 4;
|
||||
asbd.m_bits_per_channel = 16;
|
||||
let mut out = [0.0_f32; 4];
|
||||
let frames =
|
||||
convert_buffer_list_to_interleaved_f32(asbd, &list, 2, 48_000.0, 2, &mut out).unwrap();
|
||||
assert_eq!(2, frames);
|
||||
assert!((out[0] - 0.0).abs() < 0.00001);
|
||||
assert!((out[1] - 0.5).abs() < 0.00001);
|
||||
assert!((out[2] - -1.0).abs() < 0.00001);
|
||||
assert!((out[3] - 0.9999695).abs() < 0.00001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_buffer_list_to_interleaved_f32_handles_float64_input() {
|
||||
let samples = [-0.25_f64, 0.75];
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(1, &samples)],
|
||||
};
|
||||
let mut asbd = build_input_asbd(48_000.0, 1, false);
|
||||
asbd.m_bytes_per_packet = 8;
|
||||
asbd.m_bytes_per_frame = 8;
|
||||
asbd.m_bits_per_channel = 64;
|
||||
let mut out = [0.0_f32; 4];
|
||||
let frames =
|
||||
convert_buffer_list_to_interleaved_f32(asbd, &list, 2, 48_000.0, 2, &mut out).unwrap();
|
||||
assert_eq!(2, frames);
|
||||
assert_eq!([-0.25, -0.25, 0.75, 0.75], out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_buffer_list_to_interleaved_f32_handles_padded_float32_fallback() {
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes[0..4].copy_from_slice(&0.5_f32.to_ne_bytes());
|
||||
bytes[8..12].copy_from_slice(&(-0.25_f32).to_ne_bytes());
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(1, &bytes)],
|
||||
};
|
||||
let mut asbd = build_input_asbd(48_000.0, 1, false);
|
||||
asbd.m_format_flags &= !K_LINEAR_PCM_FORMAT_FLAG_IS_PACKED;
|
||||
asbd.m_bytes_per_packet = 8;
|
||||
asbd.m_bytes_per_frame = 8;
|
||||
let mut out = [0.0_f32; 4];
|
||||
let frames =
|
||||
convert_buffer_list_to_interleaved_f32(asbd, &list, 2, 48_000.0, 2, &mut out).unwrap();
|
||||
assert_eq!(2, frames);
|
||||
assert_eq!([0.5, 0.5, -0.25, -0.25], out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_buffer_list_to_interleaved_f32_linearly_resamples_to_target_rate() {
|
||||
let samples = [0.0_f32, 1.0];
|
||||
let list = AudioBufferListN {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer::from_slice(1, &samples)],
|
||||
};
|
||||
let asbd = build_input_asbd(24_000.0, 1, false);
|
||||
let mut out = [0.0_f32; 8];
|
||||
let frames =
|
||||
convert_buffer_list_to_interleaved_f32(asbd, &list, 2, 48_000.0, 2, &mut out).unwrap();
|
||||
assert_eq!(4, frames);
|
||||
assert_eq!([0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0], out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const ENC_DID_OUTPUT_SAMPLE: &str = "v40@0:8@16^{opaqueCMSampleBuffer=}24q32";
|
||||
pub const ENC_DID_STOP_WITH_ERROR: &str = "v32@0:8@16@24";
|
||||
|
||||
pub const DEFAULT_TARGET_SAMPLE_RATE: f64 = 48_000.0;
|
||||
pub const DEFAULT_TARGET_CHANNELS: u32 = 2;
|
||||
pub const MAX_CALLBACK_INPUT_FRAMES: u32 = 48_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SourceOptions {
|
||||
pub target_sample_rate: f64,
|
||||
pub target_channels: u32,
|
||||
}
|
||||
|
||||
impl Default for SourceOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target_sample_rate: DEFAULT_TARGET_SAMPLE_RATE,
|
||||
target_channels: DEFAULT_TARGET_CHANNELS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encoding_strings_have_expected_lengths() {
|
||||
assert_eq!(
|
||||
"v40@0:8@16^{opaqueCMSampleBuffer=}24q32",
|
||||
ENC_DID_OUTPUT_SAMPLE
|
||||
);
|
||||
assert_eq!("v32@0:8@16@24", ENC_DID_STOP_WITH_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_options_default_to_the_public_audio_contract() {
|
||||
let options = SourceOptions::default();
|
||||
assert_eq!(48_000.0, options.target_sample_rate);
|
||||
assert_eq!(2, options.target_channels);
|
||||
assert_eq!(48_000, MAX_CALLBACK_INPUT_FRAMES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::ffi::{c_char, c_void};
|
||||
|
||||
pub const BLOCK_HAS_COPY_DISPOSE: i32 = 1 << 25;
|
||||
pub const BLOCK_HAS_SIGNATURE: i32 = 1 << 30;
|
||||
|
||||
pub const NS_ERROR_BLOCK_SIGNATURE: &[u8] = b"v16@?0@8\0";
|
||||
pub const CONTENT_ERROR_BLOCK_SIGNATURE: &[u8] = b"v24@?0@8@16\0";
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BlockDescriptorSig {
|
||||
pub reserved: u64,
|
||||
pub size: u64,
|
||||
pub signature: *const c_char,
|
||||
}
|
||||
|
||||
pub type NsErrorUserInvoke = unsafe extern "C" fn(ctx: *mut c_void, err: *mut c_void);
|
||||
pub type NsErrorInvoke = unsafe extern "C" fn(block: *mut NSErrorBlock, err: *mut c_void);
|
||||
|
||||
#[repr(C)]
|
||||
pub struct NSErrorBlock {
|
||||
pub isa: *mut c_void,
|
||||
pub flags: i32,
|
||||
pub reserved: i32,
|
||||
pub invoke: NsErrorInvoke,
|
||||
pub descriptor: *const BlockDescriptorSig,
|
||||
pub ctx: *mut c_void,
|
||||
pub user_invoke: NsErrorUserInvoke,
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn ns_error_trampoline(block: *mut NSErrorBlock, err: *mut c_void) {
|
||||
if let Some(block) = unsafe { block.as_mut() } {
|
||||
unsafe { (block.user_invoke)(block.ctx, err) };
|
||||
}
|
||||
}
|
||||
|
||||
pub type ContentErrorUserInvoke =
|
||||
unsafe extern "C" fn(ctx: *mut c_void, content: *mut c_void, err: *mut c_void);
|
||||
pub type ContentErrorInvoke =
|
||||
unsafe extern "C" fn(block: *mut ContentErrorBlock, content: *mut c_void, err: *mut c_void);
|
||||
|
||||
#[repr(C)]
|
||||
pub struct ContentErrorBlock {
|
||||
pub isa: *mut c_void,
|
||||
pub flags: i32,
|
||||
pub reserved: i32,
|
||||
pub invoke: ContentErrorInvoke,
|
||||
pub descriptor: *const BlockDescriptorSig,
|
||||
pub ctx: *mut c_void,
|
||||
pub user_invoke: ContentErrorUserInvoke,
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn content_error_trampoline(
|
||||
block: *mut ContentErrorBlock,
|
||||
content: *mut c_void,
|
||||
err: *mut c_void,
|
||||
) {
|
||||
if let Some(block) = unsafe { block.as_mut() } {
|
||||
unsafe { (block.user_invoke)(block.ctx, content, err) };
|
||||
}
|
||||
}
|
||||
|
||||
pub const NS_ERROR_BLOCK_DESCRIPTOR: BlockDescriptorSig = BlockDescriptorSig {
|
||||
reserved: 0,
|
||||
size: std::mem::size_of::<NSErrorBlock>() as u64,
|
||||
signature: NS_ERROR_BLOCK_SIGNATURE.as_ptr().cast(),
|
||||
};
|
||||
|
||||
pub const CONTENT_ERROR_BLOCK_DESCRIPTOR: BlockDescriptorSig = BlockDescriptorSig {
|
||||
reserved: 0,
|
||||
size: std::mem::size_of::<ContentErrorBlock>() as u64,
|
||||
signature: CONTENT_ERROR_BLOCK_SIGNATURE.as_ptr().cast(),
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[repr(C)]
|
||||
struct RoundTripCtx {
|
||||
seen_err: *mut c_void,
|
||||
hit_count: u32,
|
||||
}
|
||||
|
||||
unsafe extern "C" fn round_trip_user_invoke(ctx: *mut c_void, err: *mut c_void) {
|
||||
let ctx = unsafe { &mut *(ctx.cast::<RoundTripCtx>()) };
|
||||
ctx.seen_err = err;
|
||||
ctx.hit_count += 1;
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ContentErrorRoundTripCtx {
|
||||
seen_content: *mut c_void,
|
||||
seen_err: *mut c_void,
|
||||
hit_count: u32,
|
||||
}
|
||||
|
||||
unsafe extern "C" fn content_error_user_invoke(
|
||||
ctx: *mut c_void,
|
||||
content: *mut c_void,
|
||||
err: *mut c_void,
|
||||
) {
|
||||
let ctx = unsafe { &mut *(ctx.cast::<ContentErrorRoundTripCtx>()) };
|
||||
ctx.seen_content = content;
|
||||
ctx.seen_err = err;
|
||||
ctx.hit_count += 1;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_layout_offsets_match_clang_abi_spec() {
|
||||
assert_eq!(0, std::mem::offset_of!(NSErrorBlock, isa));
|
||||
assert_eq!(8, std::mem::offset_of!(NSErrorBlock, flags));
|
||||
assert_eq!(12, std::mem::offset_of!(NSErrorBlock, reserved));
|
||||
assert_eq!(16, std::mem::offset_of!(NSErrorBlock, invoke));
|
||||
assert_eq!(24, std::mem::offset_of!(NSErrorBlock, descriptor));
|
||||
assert_eq!(32, std::mem::offset_of!(NSErrorBlock, ctx));
|
||||
assert_eq!(40, std::mem::offset_of!(NSErrorBlock, user_invoke));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_descriptor_sig_offsets_match_clang_abi_spec() {
|
||||
assert_eq!(0, std::mem::offset_of!(BlockDescriptorSig, reserved));
|
||||
assert_eq!(8, std::mem::offset_of!(BlockDescriptorSig, size));
|
||||
assert_eq!(16, std::mem::offset_of!(BlockDescriptorSig, signature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_round_trip_via_direct_trampoline_call() {
|
||||
let mut ctx = RoundTripCtx {
|
||||
seen_err: std::ptr::null_mut(),
|
||||
hit_count: 0,
|
||||
};
|
||||
let mut block = NSErrorBlock {
|
||||
isa: std::ptr::null_mut(),
|
||||
flags: BLOCK_HAS_SIGNATURE,
|
||||
reserved: 0,
|
||||
invoke: ns_error_trampoline,
|
||||
descriptor: &NS_ERROR_BLOCK_DESCRIPTOR,
|
||||
ctx: (&mut ctx as *mut RoundTripCtx).cast(),
|
||||
user_invoke: round_trip_user_invoke,
|
||||
};
|
||||
let sentinel = 0xCAFE_F00D_usize as *mut c_void;
|
||||
|
||||
unsafe { (block.invoke)(&mut block, sentinel) };
|
||||
|
||||
assert_eq!(1, ctx.hit_count);
|
||||
assert_eq!(sentinel, ctx.seen_err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_error_block_layout_offsets_match_clang_abi_spec() {
|
||||
assert_eq!(0, std::mem::offset_of!(ContentErrorBlock, isa));
|
||||
assert_eq!(16, std::mem::offset_of!(ContentErrorBlock, invoke));
|
||||
assert_eq!(24, std::mem::offset_of!(ContentErrorBlock, descriptor));
|
||||
assert_eq!(32, std::mem::offset_of!(ContentErrorBlock, ctx));
|
||||
assert_eq!(40, std::mem::offset_of!(ContentErrorBlock, user_invoke));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_error_block_round_trip_via_direct_trampoline_call() {
|
||||
let mut ctx = ContentErrorRoundTripCtx {
|
||||
seen_content: std::ptr::null_mut(),
|
||||
seen_err: std::ptr::null_mut(),
|
||||
hit_count: 0,
|
||||
};
|
||||
let mut block = ContentErrorBlock {
|
||||
isa: std::ptr::null_mut(),
|
||||
flags: BLOCK_HAS_SIGNATURE,
|
||||
reserved: 0,
|
||||
invoke: content_error_trampoline,
|
||||
descriptor: &CONTENT_ERROR_BLOCK_DESCRIPTOR,
|
||||
ctx: (&mut ctx as *mut ContentErrorRoundTripCtx).cast(),
|
||||
user_invoke: content_error_user_invoke,
|
||||
};
|
||||
let content_sentinel = 0xDEAD_BEEF_usize as *mut c_void;
|
||||
let err_sentinel = 0xCAFE_F00D_usize as *mut c_void;
|
||||
|
||||
unsafe { (block.invoke)(&mut block, content_sentinel, err_sentinel) };
|
||||
|
||||
assert_eq!(1, ctx.hit_count);
|
||||
assert_eq!(content_sentinel, ctx.seen_content);
|
||||
assert_eq!(err_sentinel, ctx.seen_err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub type OSStatus = i32;
|
||||
pub type AudioObjectId = u32;
|
||||
|
||||
pub const NO_ERR: OSStatus = 0;
|
||||
pub const K_AUDIO_OBJECT_UNKNOWN: AudioObjectId = 0;
|
||||
pub const K_AUDIO_OBJECT_SYSTEM_OBJECT: AudioObjectId = 1;
|
||||
pub const K_AUDIO_OBJECT_PROPERTY_ELEMENT_MAIN: u32 = 0;
|
||||
pub const K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL: u32 = fourcc(*b"glob");
|
||||
pub const K_AUDIO_HARDWARE_PROPERTY_TRANSLATE_PID_TO_PROCESS_OBJECT: u32 = fourcc(*b"id2p");
|
||||
pub const K_AUDIO_HARDWARE_PROPERTY_PROCESS_OBJECT_LIST: u32 = fourcc(*b"prs#");
|
||||
pub const K_AUDIO_TAP_PROPERTY_UID: u32 = fourcc(*b"tuid");
|
||||
pub const K_AUDIO_TAP_PROPERTY_FORMAT: u32 = fourcc(*b"tfmt");
|
||||
pub const K_AUDIO_AGGREGATE_DRIFT_COMPENSATION_MEDIUM_QUALITY: u32 = 0x40;
|
||||
pub const TARGET_SAMPLE_RATE: f64 = 48_000.0;
|
||||
pub const TARGET_CHANNELS: u32 = 2;
|
||||
pub const MAX_CALLBACK_INPUT_FRAMES: u32 = 48_000;
|
||||
pub const MAX_RELATED_PROCESSES: usize = 512;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AudioObjectPropertyAddress {
|
||||
pub selector: u32,
|
||||
pub scope: u32,
|
||||
pub element: u32,
|
||||
}
|
||||
|
||||
pub const fn fourcc(bytes: [u8; 4]) -> u32 {
|
||||
((bytes[0] as u32) << 24)
|
||||
| ((bytes[1] as u32) << 16)
|
||||
| ((bytes[2] as u32) << 8)
|
||||
| bytes[3] as u32
|
||||
}
|
||||
|
||||
pub fn dedupe_audio_objects(
|
||||
objects: impl IntoIterator<Item = AudioObjectId>,
|
||||
) -> Vec<AudioObjectId> {
|
||||
let mut out = Vec::new();
|
||||
for object in objects {
|
||||
if object == K_AUDIO_OBJECT_UNKNOWN || out.contains(&object) {
|
||||
continue;
|
||||
}
|
||||
out.push(object);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn core_audio_fourcc_constants_match_headers() {
|
||||
assert_eq!(0x676c_6f62, K_AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL);
|
||||
assert_eq!(
|
||||
0x6964_3270,
|
||||
K_AUDIO_HARDWARE_PROPERTY_TRANSLATE_PID_TO_PROCESS_OBJECT
|
||||
);
|
||||
assert_eq!(0x7475_6964, K_AUDIO_TAP_PROPERTY_UID);
|
||||
assert_eq!(0x7466_6d74, K_AUDIO_TAP_PROPERTY_FORMAT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_audio_process_object_collection_keeps_unique_translated_objects() {
|
||||
assert_eq!(0, K_AUDIO_OBJECT_UNKNOWN);
|
||||
assert_eq!(1, K_AUDIO_OBJECT_SYSTEM_OBJECT);
|
||||
let _ = K_AUDIO_HARDWARE_PROPERTY_PROCESS_OBJECT_LIST;
|
||||
assert_eq!(vec![7, 8, 9], dedupe_audio_objects([0, 7, 8, 7, 0, 9, 8]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod audio_converter;
|
||||
pub mod audio_source_contract;
|
||||
pub mod blocks;
|
||||
pub mod coreaudio_tap;
|
||||
pub mod process_tree;
|
||||
pub mod sck_geometry;
|
||||
pub mod source_state;
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Info {
|
||||
pub pid: i32,
|
||||
pub parent_pid: i32,
|
||||
pub process_group_id: i32,
|
||||
}
|
||||
|
||||
pub fn is_same_launch_tree_with_resolver(
|
||||
candidate_pid: i32,
|
||||
target_pid: i32,
|
||||
target_info: Option<Info>,
|
||||
mut resolver: impl FnMut(i32) -> Option<Info>,
|
||||
) -> bool {
|
||||
if candidate_pid <= 0 || target_pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
if candidate_pid == target_pid {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(mut current) = resolver(candidate_pid) else {
|
||||
return false;
|
||||
};
|
||||
if shares_process_group(current, target_pid, target_info) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for _ in 0..64 {
|
||||
let parent = current.parent_pid;
|
||||
if parent == target_pid {
|
||||
return true;
|
||||
}
|
||||
if parent <= 1 || parent == current.pid {
|
||||
return false;
|
||||
}
|
||||
let Some(next) = resolver(parent) else {
|
||||
return false;
|
||||
};
|
||||
current = next;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn collect_related_pids_with_resolver(
|
||||
target_pid: i32,
|
||||
target_info: Option<Info>,
|
||||
candidates: &[i32],
|
||||
max_count: usize,
|
||||
mut resolver: impl FnMut(i32) -> Option<Info>,
|
||||
) -> Vec<i32> {
|
||||
if target_pid <= 0 || max_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::with_capacity(candidates.len().max(1).min(max_count));
|
||||
append_pid(&mut out, max_count, target_pid);
|
||||
|
||||
for &pid in candidates {
|
||||
if out.len() >= max_count {
|
||||
break;
|
||||
}
|
||||
if pid <= 0 || pid == target_pid {
|
||||
continue;
|
||||
}
|
||||
if !is_same_launch_tree_with_resolver(pid, target_pid, target_info, &mut resolver) {
|
||||
continue;
|
||||
}
|
||||
append_pid(&mut out, max_count, pid);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn shares_process_group(candidate_info: Info, target_pid: i32, target_info: Option<Info>) -> bool {
|
||||
let candidate_group = candidate_info.process_group_id;
|
||||
if candidate_group <= 0 {
|
||||
return false;
|
||||
}
|
||||
if candidate_group == target_pid {
|
||||
return true;
|
||||
}
|
||||
target_info.is_some_and(|target| {
|
||||
target.process_group_id > 0 && candidate_group == target.process_group_id
|
||||
})
|
||||
}
|
||||
|
||||
fn append_pid(pids: &mut Vec<i32>, max_count: usize, pid: i32) -> bool {
|
||||
if pids.contains(&pid) {
|
||||
return true;
|
||||
}
|
||||
if pids.len() >= max_count {
|
||||
return false;
|
||||
}
|
||||
pids.push(pid);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn resolve_from<'a>(infos: &'a [Info]) -> impl FnMut(i32) -> Option<Info> + 'a {
|
||||
move |pid| infos.iter().copied().find(|info| info.pid == pid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_launch_tree_includes_direct_child() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 100,
|
||||
parent_pid: 1,
|
||||
process_group_id: 100,
|
||||
},
|
||||
Info {
|
||||
pid: 101,
|
||||
parent_pid: 100,
|
||||
process_group_id: 100,
|
||||
},
|
||||
];
|
||||
|
||||
assert!(is_same_launch_tree_with_resolver(
|
||||
101,
|
||||
100,
|
||||
Some(infos[0]),
|
||||
resolve_from(&infos),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_launch_tree_includes_process_group_peer() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 200,
|
||||
parent_pid: 1,
|
||||
process_group_id: 200,
|
||||
},
|
||||
Info {
|
||||
pid: 201,
|
||||
parent_pid: 1,
|
||||
process_group_id: 200,
|
||||
},
|
||||
];
|
||||
|
||||
assert!(is_same_launch_tree_with_resolver(
|
||||
201,
|
||||
200,
|
||||
Some(infos[0]),
|
||||
resolve_from(&infos),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_launch_tree_excludes_unrelated_process() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 300,
|
||||
parent_pid: 1,
|
||||
process_group_id: 300,
|
||||
},
|
||||
Info {
|
||||
pid: 301,
|
||||
parent_pid: 1,
|
||||
process_group_id: 301,
|
||||
},
|
||||
];
|
||||
|
||||
assert!(!is_same_launch_tree_with_resolver(
|
||||
301,
|
||||
300,
|
||||
Some(infos[0]),
|
||||
resolve_from(&infos),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_related_pids_with_resolver_returns_selected_app_process_tree() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 400,
|
||||
parent_pid: 1,
|
||||
process_group_id: 400,
|
||||
},
|
||||
Info {
|
||||
pid: 401,
|
||||
parent_pid: 400,
|
||||
process_group_id: 400,
|
||||
},
|
||||
Info {
|
||||
pid: 402,
|
||||
parent_pid: 401,
|
||||
process_group_id: 400,
|
||||
},
|
||||
Info {
|
||||
pid: 500,
|
||||
parent_pid: 1,
|
||||
process_group_id: 500,
|
||||
},
|
||||
];
|
||||
let candidates = [500, 401, 402, 400];
|
||||
|
||||
assert_eq!(
|
||||
vec![400, 401, 402],
|
||||
collect_related_pids_with_resolver(
|
||||
400,
|
||||
Some(infos[0]),
|
||||
&candidates,
|
||||
8,
|
||||
resolve_from(&infos),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_parent_chain_is_bounded() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 600,
|
||||
parent_pid: 1,
|
||||
process_group_id: 600,
|
||||
},
|
||||
Info {
|
||||
pid: 601,
|
||||
parent_pid: 602,
|
||||
process_group_id: 601,
|
||||
},
|
||||
Info {
|
||||
pid: 602,
|
||||
parent_pid: 601,
|
||||
process_group_id: 602,
|
||||
},
|
||||
];
|
||||
|
||||
assert!(!is_same_launch_tree_with_resolver(
|
||||
601,
|
||||
600,
|
||||
Some(infos[0]),
|
||||
resolve_from(&infos),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CGPoint {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CGSize {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CGRect {
|
||||
pub origin: CGPoint,
|
||||
pub size: CGSize,
|
||||
}
|
||||
|
||||
impl CGRect {
|
||||
pub fn standardized(mut self) -> Self {
|
||||
if self.size.width < 0.0 {
|
||||
self.origin.x += self.size.width;
|
||||
self.size.width = -self.size.width;
|
||||
}
|
||||
if self.size.height < 0.0 {
|
||||
self.origin.y += self.size.height;
|
||||
self.size.height = -self.size.height;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn intersection_area(a_raw: Self, b_raw: Self) -> f64 {
|
||||
let a = a_raw.standardized();
|
||||
let b = b_raw.standardized();
|
||||
if a.size.width <= 0.0
|
||||
|| a.size.height <= 0.0
|
||||
|| b.size.width <= 0.0
|
||||
|| b.size.height <= 0.0
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
let ax2 = a.origin.x + a.size.width;
|
||||
let ay2 = a.origin.y + a.size.height;
|
||||
let bx2 = b.origin.x + b.size.width;
|
||||
let by2 = b.origin.y + b.size.height;
|
||||
let x1 = a.origin.x.max(b.origin.x);
|
||||
let y1 = a.origin.y.max(b.origin.y);
|
||||
let x2 = ax2.min(bx2);
|
||||
let y2 = ay2.min(by2);
|
||||
if x2 <= x1 || y2 <= y1 {
|
||||
return 0.0;
|
||||
}
|
||||
(x2 - x1) * (y2 - y1)
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CMTime {
|
||||
pub value: i64,
|
||||
pub timescale: i32,
|
||||
pub flags: u32,
|
||||
pub epoch: i64,
|
||||
}
|
||||
|
||||
impl CMTime {
|
||||
pub fn seconds(value: i64, timescale: i32) -> Self {
|
||||
Self {
|
||||
value,
|
||||
timescale,
|
||||
flags: 1,
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cgrect_intersection_handles_negative_and_disjoint_rectangles() {
|
||||
let a = CGRect {
|
||||
origin: CGPoint { x: 0.0, y: 0.0 },
|
||||
size: CGSize {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
let b = CGRect {
|
||||
origin: CGPoint { x: 5.0, y: 5.0 },
|
||||
size: CGSize {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
assert_eq!(25.0, CGRect::intersection_area(a, b));
|
||||
|
||||
let c = CGRect {
|
||||
origin: CGPoint { x: 10.0, y: 10.0 },
|
||||
size: CGSize {
|
||||
width: -5.0,
|
||||
height: -5.0,
|
||||
},
|
||||
};
|
||||
assert_eq!(25.0, CGRect::intersection_area(a, c));
|
||||
|
||||
let d = CGRect {
|
||||
origin: CGPoint { x: 20.0, y: 20.0 },
|
||||
size: CGSize {
|
||||
width: 2.0,
|
||||
height: 2.0,
|
||||
},
|
||||
};
|
||||
assert_eq!(0.0, CGRect::intersection_area(a, d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_graphics_struct_layouts_match_64_bit_darwin_abi() {
|
||||
assert_eq!(16, std::mem::size_of::<CGPoint>());
|
||||
assert_eq!(16, std::mem::size_of::<CGSize>());
|
||||
assert_eq!(32, std::mem::size_of::<CGRect>());
|
||||
assert_eq!(24, std::mem::size_of::<CMTime>());
|
||||
assert_eq!(16, std::mem::offset_of!(CMTime, epoch));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u32)]
|
||||
pub enum State {
|
||||
Idle = 0,
|
||||
Starting = 1,
|
||||
Running = 2,
|
||||
Stopping = 3,
|
||||
Stopped = 4,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn from_raw(raw: u32) -> Self {
|
||||
match raw {
|
||||
0 => Self::Idle,
|
||||
1 => Self::Starting,
|
||||
2 => Self::Running,
|
||||
3 => Self::Stopping,
|
||||
4 => Self::Stopped,
|
||||
_ => Self::Stopped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransitionError {
|
||||
IllegalTransition,
|
||||
DoubleStart,
|
||||
StopBeforeStart,
|
||||
StartWhileStopping,
|
||||
}
|
||||
|
||||
pub fn is_allowed(from: State, to: State) -> bool {
|
||||
match from {
|
||||
State::Idle => matches!(to, State::Starting | State::Stopped),
|
||||
State::Starting => matches!(to, State::Running | State::Stopped),
|
||||
State::Running => matches!(to, State::Stopping | State::Stopped),
|
||||
State::Stopping => to == State::Stopped,
|
||||
State::Stopped => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Machine {
|
||||
state: AtomicU32,
|
||||
}
|
||||
|
||||
impl Default for Machine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Machine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: AtomicU32::new(State::Idle as u32),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> State {
|
||||
State::from_raw(self.state.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
fn cas(&self, from: State, to: State) -> Result<(), TransitionError> {
|
||||
if !is_allowed(from, to) {
|
||||
return Err(TransitionError::IllegalTransition);
|
||||
}
|
||||
self.state
|
||||
.compare_exchange(from as u32, to as u32, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map(|_| ())
|
||||
.map_err(|_| TransitionError::IllegalTransition)
|
||||
}
|
||||
|
||||
pub fn request_start(&self) -> Result<(), TransitionError> {
|
||||
match self.current() {
|
||||
State::Idle => self.cas(State::Idle, State::Starting),
|
||||
State::Starting | State::Running => Err(TransitionError::DoubleStart),
|
||||
State::Stopping => Err(TransitionError::StartWhileStopping),
|
||||
State::Stopped => Err(TransitionError::IllegalTransition),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_running(&self) -> Result<(), TransitionError> {
|
||||
self.cas(State::Starting, State::Running)
|
||||
}
|
||||
|
||||
pub fn request_stop(&self) -> Result<(), TransitionError> {
|
||||
match self.current() {
|
||||
State::Running => self.cas(State::Running, State::Stopping),
|
||||
State::Idle => Err(TransitionError::StopBeforeStart),
|
||||
State::Starting | State::Stopping | State::Stopped => {
|
||||
Err(TransitionError::IllegalTransition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_stopped(&self) -> Result<(), TransitionError> {
|
||||
self.cas(State::Stopping, State::Stopped)
|
||||
}
|
||||
|
||||
pub fn cancel_idle(&self) -> Result<(), TransitionError> {
|
||||
self.cas(State::Idle, State::Stopped)
|
||||
}
|
||||
|
||||
pub fn mark_fatal(&self) -> State {
|
||||
loop {
|
||||
let raw = self.state.load(Ordering::Acquire);
|
||||
let prev = State::from_raw(raw);
|
||||
if prev == State::Stopped {
|
||||
return prev;
|
||||
}
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(
|
||||
raw,
|
||||
State::Stopped as u32,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn force_state(&self, state: State) {
|
||||
self.state.store(state as u32, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::thread;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_allowed_exhaustive_transition_table() {
|
||||
let all = [
|
||||
State::Idle,
|
||||
State::Starting,
|
||||
State::Running,
|
||||
State::Stopping,
|
||||
State::Stopped,
|
||||
];
|
||||
let allowed = [
|
||||
(State::Idle, State::Starting),
|
||||
(State::Idle, State::Stopped),
|
||||
(State::Starting, State::Running),
|
||||
(State::Starting, State::Stopped),
|
||||
(State::Running, State::Stopping),
|
||||
(State::Running, State::Stopped),
|
||||
(State::Stopping, State::Stopped),
|
||||
];
|
||||
for from in all {
|
||||
for to in all {
|
||||
assert_eq!(allowed.contains(&(from, to)), is_allowed(from, to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_idle_starting_running_stopping_stopped() {
|
||||
let m = Machine::new();
|
||||
assert_eq!(State::Idle, m.current());
|
||||
m.request_start().unwrap();
|
||||
assert_eq!(State::Starting, m.current());
|
||||
m.mark_running().unwrap();
|
||||
assert_eq!(State::Running, m.current());
|
||||
m.request_stop().unwrap();
|
||||
assert_eq!(State::Stopping, m.current());
|
||||
m.mark_stopped().unwrap();
|
||||
assert_eq!(State::Stopped, m.current());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_start_rejected() {
|
||||
let m = Machine::new();
|
||||
m.request_start().unwrap();
|
||||
assert_eq!(Err(TransitionError::DoubleStart), m.request_start());
|
||||
m.mark_running().unwrap();
|
||||
assert_eq!(Err(TransitionError::DoubleStart), m.request_start());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_before_start_rejected() {
|
||||
let m = Machine::new();
|
||||
assert_eq!(Err(TransitionError::StopBeforeStart), m.request_stop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_while_stopping_rejected() {
|
||||
let m = Machine::new();
|
||||
m.request_start().unwrap();
|
||||
m.mark_running().unwrap();
|
||||
m.request_stop().unwrap();
|
||||
assert_eq!(Err(TransitionError::StartWhileStopping), m.request_start());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_idle_short_circuits_idle_to_stopped() {
|
||||
let m = Machine::new();
|
||||
m.cancel_idle().unwrap();
|
||||
assert_eq!(State::Stopped, m.current());
|
||||
assert_eq!(Err(TransitionError::IllegalTransition), m.request_start());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_fatal_forces_stopped_from_any_state() {
|
||||
for start in [
|
||||
State::Idle,
|
||||
State::Starting,
|
||||
State::Running,
|
||||
State::Stopping,
|
||||
] {
|
||||
let m = Machine::new();
|
||||
m.force_state(start);
|
||||
assert_eq!(start, m.mark_fatal());
|
||||
assert_eq!(State::Stopped, m.current());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_fatal_idempotent() {
|
||||
let m = Machine::new();
|
||||
m.force_state(State::Stopped);
|
||||
assert_eq!(State::Stopped, m.mark_fatal());
|
||||
assert_eq!(State::Stopped, m.current());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_mark_running_vs_request_stop_reaches_consistent_terminal() {
|
||||
for _ in 0..200 {
|
||||
let machine = Arc::new(Machine::new());
|
||||
machine.request_start().unwrap();
|
||||
let run_wins = Arc::new(AtomicU32::new(0));
|
||||
let stop_wins = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let runner_machine = Arc::clone(&machine);
|
||||
let runner_wins = Arc::clone(&run_wins);
|
||||
let runner = thread::spawn(move || {
|
||||
if runner_machine.mark_running().is_ok() {
|
||||
runner_wins.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
|
||||
let stopper_machine = Arc::clone(&machine);
|
||||
let stopper_wins = Arc::clone(&stop_wins);
|
||||
let stopper = thread::spawn(move || {
|
||||
for _ in 0..1000 {
|
||||
if stopper_machine.request_stop().is_ok() {
|
||||
stopper_wins.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
});
|
||||
|
||||
runner.join().unwrap();
|
||||
stopper.join().unwrap();
|
||||
assert_eq!(1, run_wins.load(Ordering::Relaxed));
|
||||
let stop_wins = stop_wins.load(Ordering::Relaxed);
|
||||
assert!(stop_wins <= 1);
|
||||
assert_eq!(
|
||||
if stop_wins == 1 {
|
||||
State::Stopping
|
||||
} else {
|
||||
State::Running
|
||||
},
|
||||
machine.current()
|
||||
);
|
||||
if stop_wins == 0 {
|
||||
machine.request_stop().unwrap();
|
||||
}
|
||||
assert_eq!(State::Stopping, machine.current());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SysctlFailure {
|
||||
pub errno: i32,
|
||||
}
|
||||
|
||||
pub const ENOENT: i32 = 2;
|
||||
|
||||
pub fn errno_message(errno: i32) -> String {
|
||||
format!("sysctlbyname failed (errno {errno})")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn errno_message_formats_errno_into_expected_wording() {
|
||||
assert_eq!("sysctlbyname failed (errno 22)", errno_message(22));
|
||||
assert_eq!("sysctlbyname failed (errno 2)", errno_message(ENOENT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod probe_helpers;
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub fn vendor_name(vendor_id: u32) -> Option<&'static str> {
|
||||
match vendor_id {
|
||||
0x1002 | 0x1022 => Some("AMD"),
|
||||
0x106b => Some("Apple"),
|
||||
0x10de => Some("NVIDIA"),
|
||||
0x1234 => Some("QEMU"),
|
||||
0x1414 => Some("Microsoft"),
|
||||
0x15ad => Some("VMware"),
|
||||
0x1af4 => Some("Virtio"),
|
||||
0x1b36 => Some("QEMU"),
|
||||
0x5143 => Some("Qualcomm"),
|
||||
0x8086 => Some("Intel"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vendor_id_from_name(name: &str) -> u32 {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.contains("apple") {
|
||||
return 0x106b;
|
||||
}
|
||||
if lower.contains("amd") || lower.contains("radeon") {
|
||||
return 0x1002;
|
||||
}
|
||||
if lower.contains("intel") {
|
||||
return 0x8086;
|
||||
}
|
||||
if lower.contains("nvidia") {
|
||||
return 0x10de;
|
||||
}
|
||||
if lower.contains("microsoft") {
|
||||
return 0x1414;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
pub fn write_hex_u64(value: u64) -> String {
|
||||
format!("0x{value:x}")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ParseHexIdError {
|
||||
InvalidHexId,
|
||||
InvalidDigit,
|
||||
}
|
||||
|
||||
pub fn parse_hex_id(raw: &str) -> Result<u32, ParseHexIdError> {
|
||||
let mut trimmed = raw.trim();
|
||||
if let Some(rest) = trimmed
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| trimmed.strip_prefix("0X"))
|
||||
{
|
||||
trimmed = rest;
|
||||
}
|
||||
if trimmed.is_empty() || trimmed.len() > 8 {
|
||||
return Err(ParseHexIdError::InvalidHexId);
|
||||
}
|
||||
u32::from_str_radix(trimmed, 16).map_err(|_| ParseHexIdError::InvalidDigit)
|
||||
}
|
||||
|
||||
pub fn is_drm_card_name(name: &str) -> bool {
|
||||
let Some(rest) = name.strip_prefix("card") else {
|
||||
return false;
|
||||
};
|
||||
!rest.is_empty() && rest.bytes().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub fn basename(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or(path, |(_, base)| base)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_hex_id_accepts_sysfs_hex_ids() {
|
||||
assert_eq!(0x8086, parse_hex_id("0x8086\n").unwrap());
|
||||
assert_eq!(0x10de, parse_hex_id("10DE").unwrap());
|
||||
assert_eq!(Err(ParseHexIdError::InvalidHexId), parse_hex_id("0x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_drm_card_name_accepts_cards_but_rejects_connectors_render_nodes() {
|
||||
assert!(is_drm_card_name("card0"));
|
||||
assert!(is_drm_card_name("card12"));
|
||||
assert!(!is_drm_card_name("card0-DP-1"));
|
||||
assert!(!is_drm_card_name("renderD128"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_id_helpers() {
|
||||
assert_eq!(0x106b, vendor_id_from_name("Apple M3 GPU"));
|
||||
assert_eq!(Some("NVIDIA"), vendor_name(0x10de));
|
||||
assert_eq!(None, vendor_name(0xffff));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct EnvSnapshot {
|
||||
pub hunspell_dict_dir: Option<String>,
|
||||
pub xdg_data_home: Option<String>,
|
||||
pub home: Option<String>,
|
||||
pub xdg_data_dirs: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemDictionary {
|
||||
pub tag: String,
|
||||
pub aff_path: PathBuf,
|
||||
pub dic_path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn canonicalize_tag(raw: &str) -> String {
|
||||
raw.chars()
|
||||
.map(|ch| match ch {
|
||||
'_' | '-' => '-',
|
||||
_ => ch.to_ascii_lowercase(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn build_search_path(env: &EnvSnapshot) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(raw) = &env.hunspell_dict_dir {
|
||||
dirs.extend(
|
||||
raw.split(':')
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(PathBuf::from),
|
||||
);
|
||||
}
|
||||
if let Some(xdg_home) = &env.xdg_data_home {
|
||||
dirs.push(Path::new(xdg_home).join("hunspell"));
|
||||
} else if let Some(home) = &env.home {
|
||||
dirs.push(Path::new(home).join(".local/share/hunspell"));
|
||||
}
|
||||
if let Some(xdg_dirs) = &env.xdg_data_dirs {
|
||||
dirs.extend(
|
||||
xdg_dirs
|
||||
.split(':')
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| Path::new(part).join("hunspell")),
|
||||
);
|
||||
}
|
||||
dirs.push(PathBuf::from("/usr/local/share/hunspell"));
|
||||
dirs.push(PathBuf::from("/usr/share/hunspell"));
|
||||
dirs.push(PathBuf::from("/usr/share/myspell/dicts"));
|
||||
dirs.push(PathBuf::from("/usr/share/myspell"));
|
||||
dirs
|
||||
}
|
||||
|
||||
pub fn discover_dictionaries(env: &EnvSnapshot) -> Vec<SystemDictionary> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for dir in build_search_path(env) {
|
||||
scan_dir(&dir, &mut seen, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn scan_dir(dir: &Path, seen: &mut HashSet<String>, out: &mut Vec<SystemDictionary>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("dic") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if stem.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let aff_path = dir.join(format!("{stem}.aff"));
|
||||
if !aff_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let tag = canonicalize_tag(stem);
|
||||
if !seen.insert(tag.clone()) {
|
||||
continue;
|
||||
}
|
||||
out.push(SystemDictionary {
|
||||
tag,
|
||||
aff_path,
|
||||
dic_path: path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonicalize_tag_collapses_case_and_separator_variants() {
|
||||
assert_eq!("en-us", canonicalize_tag("en_US"));
|
||||
assert_eq!("en-us", canonicalize_tag("EN-us"));
|
||||
assert_eq!("pt-br", canonicalize_tag("PT_BR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_search_path_hunspell_dict_dir_is_honoured_first() {
|
||||
let dirs = build_search_path(&EnvSnapshot {
|
||||
hunspell_dict_dir: Some("/tmp/a:/tmp/b".to_owned()),
|
||||
..EnvSnapshot::default()
|
||||
});
|
||||
assert_eq!(PathBuf::from("/tmp/a"), dirs[0]);
|
||||
assert_eq!(PathBuf::from("/tmp/b"), dirs[1]);
|
||||
assert!(
|
||||
dirs.iter()
|
||||
.any(|dir| dir == Path::new("/usr/share/hunspell"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_search_path_xdg_data_home_wins_over_home() {
|
||||
let dirs = build_search_path(&EnvSnapshot {
|
||||
xdg_data_home: Some("/x/data".to_owned()),
|
||||
home: Some("/home/user".to_owned()),
|
||||
..EnvSnapshot::default()
|
||||
});
|
||||
assert!(dirs.iter().any(|dir| dir == Path::new("/x/data/hunspell")));
|
||||
assert!(
|
||||
!dirs
|
||||
.iter()
|
||||
.any(|dir| dir == Path::new("/home/user/.local/share/hunspell"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_dictionaries_picks_up_aff_dic_pairs_and_first_tag_wins() {
|
||||
let dir_a = tempfile::tempdir().unwrap();
|
||||
let dir_b = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir_a.path().join("en_US.aff"), "").unwrap();
|
||||
std::fs::write(dir_a.path().join("en_US.dic"), "").unwrap();
|
||||
std::fs::write(dir_b.path().join("EN-us.aff"), "").unwrap();
|
||||
std::fs::write(dir_b.path().join("EN-us.dic"), "").unwrap();
|
||||
std::fs::write(dir_b.path().join("orphan.dic"), "").unwrap();
|
||||
|
||||
let dicts = discover_dictionaries(&EnvSnapshot {
|
||||
hunspell_dict_dir: Some(format!(
|
||||
"{}:{}",
|
||||
dir_a.path().display(),
|
||||
dir_b.path().display()
|
||||
)),
|
||||
..EnvSnapshot::default()
|
||||
});
|
||||
assert_eq!(1, dicts.iter().filter(|dict| dict.tag == "en-us").count());
|
||||
assert!(!dicts.iter().any(|dict| dict.tag == "orphan"));
|
||||
assert!(
|
||||
dicts
|
||||
.iter()
|
||||
.any(|dict| dict.aff_path == dir_a.path().join("en_US.aff"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub fn is_utf8_encoding(encoding: &str) -> bool {
|
||||
let bytes = encoding.as_bytes();
|
||||
if bytes.is_empty() || bytes.len() > 16 {
|
||||
return false;
|
||||
}
|
||||
bytes.eq_ignore_ascii_case(b"utf-8") || bytes.eq_ignore_ascii_case(b"utf8")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_utf8_encoding_matches_common_spellings() {
|
||||
assert!(is_utf8_encoding("UTF-8"));
|
||||
assert!(is_utf8_encoding("utf-8"));
|
||||
assert!(is_utf8_encoding("UTF8"));
|
||||
assert!(!is_utf8_encoding("ISO-8859-1"));
|
||||
assert!(!is_utf8_encoding(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_utf8_encoding_rejects_long_or_decorated_values() {
|
||||
assert!(!is_utf8_encoding("utf-8\0"));
|
||||
assert!(!is_utf8_encoding(" utf-8"));
|
||||
assert!(!is_utf8_encoding("utf-8-with-extra"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const HEX_LEN: usize = 64;
|
||||
const CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
pub fn bytes_to_hex_lower(bytes: &[u8], out: &mut [u8]) {
|
||||
assert!(out.len() >= bytes.len() * 2);
|
||||
const ALPHABET: &[u8; 16] = b"0123456789abcdef";
|
||||
for (index, byte) in bytes.iter().copied().enumerate() {
|
||||
out[index * 2] = ALPHABET[(byte >> 4) as usize];
|
||||
out[index * 2 + 1] = ALPHABET[(byte & 0x0f) as usize];
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_file_to_hex(path: impl AsRef<Path>) -> io::Result<String> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = [0_u8; CHUNK_SIZE];
|
||||
loop {
|
||||
let read = file.read(&mut buf)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..read]);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let mut out = [0_u8; HEX_LEN];
|
||||
bytes_to_hex_lower(&digest, &mut out);
|
||||
let mut hex = String::with_capacity(HEX_LEN);
|
||||
for byte in out {
|
||||
hex.push(char::from(byte));
|
||||
}
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bytes_to_hex_lower_formats_lowercase_hex() {
|
||||
let mut out = [0_u8; 8];
|
||||
bytes_to_hex_lower(&[0xde, 0xad, 0xbe, 0xef], &mut out);
|
||||
assert_eq!("deadbeef", std::str::from_utf8(&out).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_to_hex_lower_formats_zero_and_edge_bytes() {
|
||||
let mut out = [0_u8; 10];
|
||||
bytes_to_hex_lower(&[0x00, 0x0f, 0xf0, 0xff, 0x10], &mut out);
|
||||
assert_eq!("000ff0ff10", std::str::from_utf8(&out).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_file_to_hex_matches_known_sha256_for_abc() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("abc");
|
||||
std::fs::write(&path, b"abc").unwrap();
|
||||
assert_eq!(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
hash_file_to_hex(&path).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_file_to_hex_over_chunk_boundary_matches_single_shot_sha256() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("chunked");
|
||||
let mut file = File::create(&path).unwrap();
|
||||
let mut payload = vec![0_u8; 200 * 1024];
|
||||
let mut value = 0xc0ffee_u64;
|
||||
for byte in &mut payload {
|
||||
value ^= value << 13;
|
||||
value ^= value >> 7;
|
||||
value ^= value << 17;
|
||||
*byte = value as u8;
|
||||
}
|
||||
file.write_all(&payload).unwrap();
|
||||
|
||||
let mut oneshot = Sha256::new();
|
||||
oneshot.update(&payload);
|
||||
let digest = oneshot.finalize();
|
||||
let mut expected = [0_u8; HEX_LEN];
|
||||
bytes_to_hex_lower(&digest, &mut expected);
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&expected).unwrap(),
|
||||
hash_file_to_hex(&path).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_file_to_hex_returns_open_error_for_missing_file() {
|
||||
assert!(hash_file_to_hex("/nonexistent/path/that/should/not/exist.bin").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod dictionaries;
|
||||
pub mod encoding;
|
||||
pub mod hashing;
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod stats;
|
||||
@@ -0,0 +1,554 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ByteRateSample {
|
||||
pub bytes: u64,
|
||||
pub timestamp_us: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OutboundStatsEntry {
|
||||
pub track_sid: String,
|
||||
pub source: String,
|
||||
pub kind: String,
|
||||
pub codec: Option<String>,
|
||||
pub bitrate_kbps: f64,
|
||||
pub packets_lost: i64,
|
||||
pub packets_sent: u64,
|
||||
pub fps: Option<f64>,
|
||||
pub audio_level: Option<f64>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub source_width: Option<u32>,
|
||||
pub source_height: Option<u32>,
|
||||
pub target_bitrate_kbps: Option<f64>,
|
||||
pub configured_fps: Option<f64>,
|
||||
pub target_fps: Option<f64>,
|
||||
pub effective_fps: Option<f64>,
|
||||
pub frames_produced: Option<u64>,
|
||||
pub frames_accepted: Option<u64>,
|
||||
pub frames_dropped: Option<u64>,
|
||||
pub frames_coalesced: Option<u64>,
|
||||
pub frames_captured: Option<u64>,
|
||||
pub capture_failures: Option<u64>,
|
||||
pub max_queue_age_ms: Option<u64>,
|
||||
pub max_push_latency_ms: Option<u64>,
|
||||
pub adaptive_send_tier: Option<String>,
|
||||
pub adaptive_send_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct InboundStatsEntry {
|
||||
pub participant_sid: String,
|
||||
pub participant_identity: Option<String>,
|
||||
pub track_sid: String,
|
||||
pub source: Option<String>,
|
||||
pub kind: String,
|
||||
pub codec: Option<String>,
|
||||
pub bitrate_kbps: f64,
|
||||
pub packets_lost: i64,
|
||||
pub packets_received: u64,
|
||||
pub jitter_ms: Option<f64>,
|
||||
pub audio_level: Option<f64>,
|
||||
pub fps: Option<f64>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub source_width: Option<u32>,
|
||||
pub source_height: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SendHealthStats {
|
||||
pub outgoing_video_queue_depth: u64,
|
||||
pub outgoing_video_queue_capacity: u64,
|
||||
pub outgoing_video_max_queue_depth: u64,
|
||||
pub outgoing_video_frames_produced: u64,
|
||||
pub outgoing_video_frames_accepted: u64,
|
||||
pub outgoing_video_frames_dropped: u64,
|
||||
pub outgoing_video_frames_coalesced: u64,
|
||||
pub outgoing_video_frames_captured: u64,
|
||||
pub outgoing_video_capture_failures: u64,
|
||||
pub outgoing_video_effective_fps: f64,
|
||||
pub outgoing_video_target_fps: f64,
|
||||
pub outgoing_video_pacing_target_fps: f64,
|
||||
pub outgoing_video_max_queue_age_ms: u64,
|
||||
pub outgoing_video_max_push_latency_ms: u64,
|
||||
pub outgoing_video_pacing_mode: String,
|
||||
pub outgoing_video_bus_active: bool,
|
||||
pub outgoing_audio_buffer_target_ms: u32,
|
||||
pub outgoing_audio_buffer_max_ms: u32,
|
||||
pub outgoing_audio_underruns: u64,
|
||||
pub outgoing_audio_rebuffers: u64,
|
||||
pub outgoing_audio_max_frame_gap_ms: u64,
|
||||
pub adaptive_send_tier: String,
|
||||
pub adaptive_send_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ConnectionStats {
|
||||
pub rtt_ms: Option<f64>,
|
||||
pub outbound: Vec<OutboundStatsEntry>,
|
||||
pub inbound: Vec<InboundStatsEntry>,
|
||||
pub send: Option<SendHealthStats>,
|
||||
}
|
||||
|
||||
pub fn bitrate_kbps(prev: Option<ByteRateSample>, cur: ByteRateSample) -> f64 {
|
||||
let Some(prev) = prev else {
|
||||
return 0.0;
|
||||
};
|
||||
let dt_us = cur.timestamp_us - prev.timestamp_us;
|
||||
if dt_us <= 0 {
|
||||
return 0.0;
|
||||
}
|
||||
if cur.bytes < prev.bytes {
|
||||
return 0.0;
|
||||
}
|
||||
let delta_bytes = (cur.bytes - prev.bytes) as f64;
|
||||
let dt_seconds = dt_us as f64 / 1_000_000.0;
|
||||
(delta_bytes * 8.0) / dt_seconds / 1000.0
|
||||
}
|
||||
|
||||
pub fn sanitize_kbps(kbps: f64) -> f64 {
|
||||
if !kbps.is_finite() || kbps < 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(kbps * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
pub fn jitter_seconds_to_ms(jitter_s: f64) -> Option<f64> {
|
||||
if !jitter_s.is_finite() || jitter_s < 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((jitter_s * 1000.0 * 100.0).round() / 100.0)
|
||||
}
|
||||
|
||||
pub fn rtt_seconds_to_ms(rtt_s: f64) -> Option<f64> {
|
||||
if !rtt_s.is_finite() || rtt_s <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((rtt_s * 1000.0 * 100.0).round() / 100.0)
|
||||
}
|
||||
|
||||
pub fn sanitize_audio_level(level: f64) -> Option<f64> {
|
||||
if !level.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(level.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
pub fn stats_to_json(stats: &ConnectionStats) -> String {
|
||||
let rtt = match stats.rtt_ms {
|
||||
Some(ms) if ms.is_finite() && ms >= 0.0 => {
|
||||
JsonValue::Raw(fmt_num((ms * 100.0).round() / 100.0))
|
||||
}
|
||||
_ => JsonValue::Raw("null".to_string()),
|
||||
};
|
||||
let outbound_items: Vec<String> = stats.outbound.iter().map(outbound_json).collect();
|
||||
let inbound_items: Vec<String> = stats.inbound.iter().map(inbound_json).collect();
|
||||
let send = match &stats.send {
|
||||
Some(send) => JsonValue::Raw(send_health_json(send)),
|
||||
None => JsonValue::Raw("null".to_string()),
|
||||
};
|
||||
json_object(&[
|
||||
("rttMs", rtt),
|
||||
("outbound", JsonValue::Raw(raw_array(&outbound_items))),
|
||||
("inbound", JsonValue::Raw(raw_array(&inbound_items))),
|
||||
("send", send),
|
||||
])
|
||||
}
|
||||
|
||||
enum JsonValue {
|
||||
Str(String),
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
fn outbound_json(entry: &OutboundStatsEntry) -> String {
|
||||
let mut fields = vec![
|
||||
("trackSid", JsonValue::Str(entry.track_sid.clone())),
|
||||
("source", JsonValue::Str(entry.source.clone())),
|
||||
("kind", JsonValue::Str(entry.kind.clone())),
|
||||
(
|
||||
"bitrateKbps",
|
||||
JsonValue::Raw(fmt_num(sanitize_kbps(entry.bitrate_kbps))),
|
||||
),
|
||||
(
|
||||
"packetsLost",
|
||||
JsonValue::Raw(entry.packets_lost.to_string()),
|
||||
),
|
||||
(
|
||||
"packetsSent",
|
||||
JsonValue::Raw(entry.packets_sent.to_string()),
|
||||
),
|
||||
];
|
||||
if let Some(fps) = entry.fps
|
||||
&& fps.is_finite()
|
||||
&& fps >= 0.0
|
||||
{
|
||||
fields.push(("fps", JsonValue::Raw(fmt_num((fps * 10.0).round() / 10.0))));
|
||||
}
|
||||
if let Some(level) = entry.audio_level {
|
||||
fields.push((
|
||||
"audioLevel",
|
||||
JsonValue::Raw(fmt_num((level * 1000.0).round() / 1000.0)),
|
||||
));
|
||||
}
|
||||
if let Some(width) = entry.width {
|
||||
fields.push(("width", JsonValue::Raw(width.to_string())));
|
||||
}
|
||||
if let Some(height) = entry.height {
|
||||
fields.push(("height", JsonValue::Raw(height.to_string())));
|
||||
}
|
||||
if let Some(width) = entry.source_width {
|
||||
fields.push(("sourceWidth", JsonValue::Raw(width.to_string())));
|
||||
}
|
||||
if let Some(height) = entry.source_height {
|
||||
fields.push(("sourceHeight", JsonValue::Raw(height.to_string())));
|
||||
}
|
||||
if let Some(kbps) = entry.target_bitrate_kbps
|
||||
&& kbps.is_finite()
|
||||
&& kbps >= 0.0
|
||||
{
|
||||
fields.push((
|
||||
"targetBitrateKbps",
|
||||
JsonValue::Raw(fmt_num((kbps * 10.0).round() / 10.0)),
|
||||
));
|
||||
}
|
||||
if let Some(fps) = entry.configured_fps
|
||||
&& fps.is_finite()
|
||||
&& fps >= 0.0
|
||||
{
|
||||
fields.push((
|
||||
"configuredFps",
|
||||
JsonValue::Raw(fmt_num((fps * 10.0).round() / 10.0)),
|
||||
));
|
||||
}
|
||||
if let Some(fps) = entry.target_fps
|
||||
&& fps.is_finite()
|
||||
&& fps >= 0.0
|
||||
{
|
||||
fields.push((
|
||||
"targetFps",
|
||||
JsonValue::Raw(fmt_num((fps * 10.0).round() / 10.0)),
|
||||
));
|
||||
}
|
||||
if let Some(fps) = entry.effective_fps
|
||||
&& fps.is_finite()
|
||||
&& fps >= 0.0
|
||||
{
|
||||
fields.push((
|
||||
"effectiveFps",
|
||||
JsonValue::Raw(fmt_num((fps * 10.0).round() / 10.0)),
|
||||
));
|
||||
}
|
||||
if let Some(value) = entry.frames_produced {
|
||||
fields.push(("framesProduced", JsonValue::Raw(value.to_string())));
|
||||
}
|
||||
if let Some(value) = entry.frames_accepted {
|
||||
fields.push(("framesAccepted", JsonValue::Raw(value.to_string())));
|
||||
}
|
||||
if let Some(value) = entry.frames_dropped {
|
||||
fields.push(("framesDropped", JsonValue::Raw(value.to_string())));
|
||||
}
|
||||
if let Some(value) = entry.frames_coalesced {
|
||||
fields.push(("framesCoalesced", JsonValue::Raw(value.to_string())));
|
||||
}
|
||||
if let Some(value) = entry.frames_captured {
|
||||
fields.push(("framesCaptured", JsonValue::Raw(value.to_string())));
|
||||
}
|
||||
if let Some(value) = entry.capture_failures {
|
||||
fields.push(("captureFailures", JsonValue::Raw(value.to_string())));
|
||||
}
|
||||
if let Some(value) = entry.max_queue_age_ms {
|
||||
fields.push(("maxQueueAgeMs", JsonValue::Raw(value.to_string())));
|
||||
}
|
||||
if let Some(value) = entry.max_push_latency_ms {
|
||||
fields.push(("maxPushLatencyMs", JsonValue::Raw(value.to_string())));
|
||||
}
|
||||
if let Some(value) = &entry.adaptive_send_tier {
|
||||
fields.push(("adaptiveSendTier", JsonValue::Str(value.clone())));
|
||||
}
|
||||
if let Some(value) = &entry.adaptive_send_reason {
|
||||
fields.push(("adaptiveSendReason", JsonValue::Str(value.clone())));
|
||||
}
|
||||
if let Some(codec) = &entry.codec {
|
||||
fields.push(("codec", JsonValue::Str(codec.clone())));
|
||||
}
|
||||
json_object(&fields)
|
||||
}
|
||||
|
||||
fn inbound_json(entry: &InboundStatsEntry) -> String {
|
||||
let mut fields = vec![
|
||||
(
|
||||
"participantSid",
|
||||
JsonValue::Str(entry.participant_sid.clone()),
|
||||
),
|
||||
("trackSid", JsonValue::Str(entry.track_sid.clone())),
|
||||
("kind", JsonValue::Str(entry.kind.clone())),
|
||||
(
|
||||
"bitrateKbps",
|
||||
JsonValue::Raw(fmt_num(sanitize_kbps(entry.bitrate_kbps))),
|
||||
),
|
||||
(
|
||||
"packetsLost",
|
||||
JsonValue::Raw(entry.packets_lost.to_string()),
|
||||
),
|
||||
(
|
||||
"packetsReceived",
|
||||
JsonValue::Raw(entry.packets_received.to_string()),
|
||||
),
|
||||
];
|
||||
if let Some(identity) = &entry.participant_identity {
|
||||
fields.push(("participantIdentity", JsonValue::Str(identity.clone())));
|
||||
}
|
||||
if let Some(source) = &entry.source {
|
||||
fields.push(("source", JsonValue::Str(source.clone())));
|
||||
}
|
||||
if let Some(jitter_ms) = entry.jitter_ms {
|
||||
fields.push(("jitterMs", JsonValue::Raw(fmt_num(jitter_ms))));
|
||||
}
|
||||
if let Some(level) = entry.audio_level {
|
||||
fields.push((
|
||||
"audioLevel",
|
||||
JsonValue::Raw(fmt_num((level * 1000.0).round() / 1000.0)),
|
||||
));
|
||||
}
|
||||
if let Some(fps) = entry.fps
|
||||
&& fps.is_finite()
|
||||
&& fps >= 0.0
|
||||
{
|
||||
fields.push(("fps", JsonValue::Raw(fmt_num((fps * 10.0).round() / 10.0))));
|
||||
}
|
||||
if let Some(width) = entry.width {
|
||||
fields.push(("width", JsonValue::Raw(width.to_string())));
|
||||
}
|
||||
if let Some(height) = entry.height {
|
||||
fields.push(("height", JsonValue::Raw(height.to_string())));
|
||||
}
|
||||
if let Some(width) = entry.source_width {
|
||||
fields.push(("sourceWidth", JsonValue::Raw(width.to_string())));
|
||||
}
|
||||
if let Some(height) = entry.source_height {
|
||||
fields.push(("sourceHeight", JsonValue::Raw(height.to_string())));
|
||||
}
|
||||
if let Some(codec) = &entry.codec {
|
||||
fields.push(("codec", JsonValue::Str(codec.clone())));
|
||||
}
|
||||
json_object(&fields)
|
||||
}
|
||||
|
||||
fn send_health_json(send: &SendHealthStats) -> String {
|
||||
json_object(&[
|
||||
(
|
||||
"outgoingVideoQueueDepth",
|
||||
JsonValue::Raw(send.outgoing_video_queue_depth.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoQueueCapacity",
|
||||
JsonValue::Raw(send.outgoing_video_queue_capacity.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoMaxQueueDepth",
|
||||
JsonValue::Raw(send.outgoing_video_max_queue_depth.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoFramesProduced",
|
||||
JsonValue::Raw(send.outgoing_video_frames_produced.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoFramesAccepted",
|
||||
JsonValue::Raw(send.outgoing_video_frames_accepted.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoFramesDropped",
|
||||
JsonValue::Raw(send.outgoing_video_frames_dropped.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoFramesCoalesced",
|
||||
JsonValue::Raw(send.outgoing_video_frames_coalesced.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoFramesCaptured",
|
||||
JsonValue::Raw(send.outgoing_video_frames_captured.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoCaptureFailures",
|
||||
JsonValue::Raw(send.outgoing_video_capture_failures.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoEffectiveFps",
|
||||
JsonValue::Raw(fmt_num(send.outgoing_video_effective_fps)),
|
||||
),
|
||||
(
|
||||
"outgoingVideoTargetFps",
|
||||
JsonValue::Raw(fmt_num(send.outgoing_video_target_fps)),
|
||||
),
|
||||
(
|
||||
"outgoingVideoPacingTargetFps",
|
||||
JsonValue::Raw(fmt_num(send.outgoing_video_pacing_target_fps)),
|
||||
),
|
||||
(
|
||||
"outgoingVideoMaxQueueAgeMs",
|
||||
JsonValue::Raw(send.outgoing_video_max_queue_age_ms.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoMaxPushLatencyMs",
|
||||
JsonValue::Raw(send.outgoing_video_max_push_latency_ms.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoPacingMode",
|
||||
JsonValue::Str(send.outgoing_video_pacing_mode.clone()),
|
||||
),
|
||||
(
|
||||
"outgoingVideoBusActive",
|
||||
JsonValue::Raw(send.outgoing_video_bus_active.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingAudioBufferTargetMs",
|
||||
JsonValue::Raw(send.outgoing_audio_buffer_target_ms.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingAudioBufferMaxMs",
|
||||
JsonValue::Raw(send.outgoing_audio_buffer_max_ms.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingAudioUnderruns",
|
||||
JsonValue::Raw(send.outgoing_audio_underruns.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingAudioRebuffers",
|
||||
JsonValue::Raw(send.outgoing_audio_rebuffers.to_string()),
|
||||
),
|
||||
(
|
||||
"outgoingAudioMaxFrameGapMs",
|
||||
JsonValue::Raw(send.outgoing_audio_max_frame_gap_ms.to_string()),
|
||||
),
|
||||
(
|
||||
"adaptiveSendTier",
|
||||
JsonValue::Str(send.adaptive_send_tier.clone()),
|
||||
),
|
||||
(
|
||||
"adaptiveSendReason",
|
||||
JsonValue::Str(send.adaptive_send_reason.clone()),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn json_object(fields: &[(&str, JsonValue)]) -> String {
|
||||
let mut out = String::from("{");
|
||||
for (i, (key, value)) in fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
push_json_string(&mut out, key);
|
||||
out.push(':');
|
||||
match value {
|
||||
JsonValue::Str(s) => push_json_string(&mut out, s),
|
||||
JsonValue::Raw(r) => out.push_str(r),
|
||||
}
|
||||
}
|
||||
out.push('}');
|
||||
out
|
||||
}
|
||||
|
||||
fn raw_array(items: &[String]) -> String {
|
||||
let mut out = String::from("[");
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(item);
|
||||
}
|
||||
out.push(']');
|
||||
out
|
||||
}
|
||||
|
||||
fn push_json_string(out: &mut String, value: &str) {
|
||||
out.push('"');
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{08}' => out.push_str("\\b"),
|
||||
'\u{0C}' => out.push_str("\\f"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
out.push_str(&format!("\\u{:04x}", c as u32));
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
fn fmt_num(value: f64) -> String {
|
||||
if !value.is_finite() {
|
||||
return "0".to_string();
|
||||
}
|
||||
if value.fract() == 0.0 {
|
||||
return (value as i64).to_string();
|
||||
}
|
||||
format!("{value}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bitrate_uses_byte_delta_over_time() {
|
||||
let prev = ByteRateSample {
|
||||
bytes: 2_000,
|
||||
timestamp_us: 1_000_000,
|
||||
};
|
||||
let cur = ByteRateSample {
|
||||
bytes: 14_500,
|
||||
timestamp_us: 1_500_000,
|
||||
};
|
||||
|
||||
assert_eq!(bitrate_kbps(Some(prev), cur), 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_json_uses_canonical_voice_contract_shape() {
|
||||
let stats = ConnectionStats {
|
||||
rtt_ms: Some(18.25),
|
||||
outbound: vec![OutboundStatsEntry {
|
||||
track_sid: "TR_audio".to_string(),
|
||||
source: "microphone".to_string(),
|
||||
kind: "audio".to_string(),
|
||||
codec: Some("audio/opus".to_string()),
|
||||
bitrate_kbps: 48.04,
|
||||
packets_lost: 0,
|
||||
fps: None,
|
||||
audio_level: Some(0.1234),
|
||||
..Default::default()
|
||||
}],
|
||||
inbound: vec![InboundStatsEntry {
|
||||
participant_sid: "PA_remote".to_string(),
|
||||
participant_identity: Some("user_2_connection_2".to_string()),
|
||||
track_sid: "TR_remote".to_string(),
|
||||
source: Some("microphone".to_string()),
|
||||
kind: "audio".to_string(),
|
||||
codec: None,
|
||||
bitrate_kbps: 31.96,
|
||||
packets_lost: 2,
|
||||
packets_received: 100,
|
||||
jitter_ms: Some(4.5),
|
||||
audio_level: Some(0.1234),
|
||||
fps: None,
|
||||
width: None,
|
||||
height: None,
|
||||
source_width: None,
|
||||
source_height: None,
|
||||
}],
|
||||
send: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
stats_to_json(&stats),
|
||||
"{\"rttMs\":18.25,\"outbound\":[{\"trackSid\":\"TR_audio\",\"source\":\"microphone\",\"kind\":\"audio\",\"bitrateKbps\":48,\"packetsLost\":0,\"packetsSent\":0,\"audioLevel\":0.123,\"codec\":\"audio/opus\"}],\"inbound\":[{\"participantSid\":\"PA_remote\",\"trackSid\":\"TR_remote\",\"kind\":\"audio\",\"bitrateKbps\":32,\"packetsLost\":2,\"packetsReceived\":100,\"participantIdentity\":\"user_2_connection_2\",\"source\":\"microphone\",\"jitterMs\":4.5,\"audioLevel\":0.123}],\"send\":null}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const TARGET_SAMPLE_RATE: u32 = 48_000;
|
||||
pub const TARGET_CHANNELS: u16 = 2;
|
||||
pub const BITS_PER_SAMPLE: u16 = 32;
|
||||
pub const BYTES_PER_SAMPLE: u16 = BITS_PER_SAMPLE / 8;
|
||||
pub const FRAME_BLOCK_ALIGN: u16 = TARGET_CHANNELS * BYTES_PER_SAMPLE;
|
||||
pub const AVG_BYTES_PER_SECOND: u32 =
|
||||
TARGET_SAMPLE_RATE * TARGET_CHANNELS as u32 * BYTES_PER_SAMPLE as u32;
|
||||
|
||||
pub fn validate_sample_rate(value: u32) -> bool {
|
||||
value == TARGET_SAMPLE_RATE
|
||||
}
|
||||
|
||||
pub fn validate_channels(value: u32) -> bool {
|
||||
value == TARGET_CHANNELS as u32
|
||||
}
|
||||
|
||||
pub fn sample_count_for_frames(frames: u32) -> Option<usize> {
|
||||
(frames as usize).checked_mul(TARGET_CHANNELS as usize)
|
||||
}
|
||||
|
||||
pub fn qpc_100ns_to_timestamp_us(qpc_100ns: u64) -> i64 {
|
||||
(qpc_100ns / 10).min(i64::MAX as u64) as i64
|
||||
}
|
||||
|
||||
pub fn pcm16_to_float32(sample: i16) -> f32 {
|
||||
sample as f32 / 32768.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_constants_describe_48khz_stereo_float32_frames() {
|
||||
assert_eq!(48_000, TARGET_SAMPLE_RATE);
|
||||
assert_eq!(2, TARGET_CHANNELS);
|
||||
assert_eq!(32, BITS_PER_SAMPLE);
|
||||
assert_eq!(8, FRAME_BLOCK_ALIGN);
|
||||
assert_eq!(384_000, AVG_BYTES_PER_SECOND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_validation_rejects_unsupported_public_shapes() {
|
||||
assert!(validate_sample_rate(48_000));
|
||||
assert!(!validate_sample_rate(44_100));
|
||||
assert!(validate_channels(2));
|
||||
assert!(!validate_channels(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_counts_and_timestamps_are_bounded() {
|
||||
assert_eq!(Some(0), sample_count_for_frames(0));
|
||||
assert_eq!(Some(2), sample_count_for_frames(1));
|
||||
assert_eq!(Some(9_600), sample_count_for_frames(4_800));
|
||||
assert_eq!(123_456, qpc_100ns_to_timestamp_us(1_234_560));
|
||||
assert_eq!((u64::MAX / 10) as i64, qpc_100ns_to_timestamp_us(u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcm16_samples_convert_to_normalized_float32_frames() {
|
||||
assert_eq!(-1.0, pcm16_to_float32(i16::MIN));
|
||||
assert_eq!(0.0, pcm16_to_float32(0));
|
||||
assert!((pcm16_to_float32(i16::MAX) - 0.9999695).abs() < 0.0000001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod audio_contract;
|
||||
pub mod process_tree;
|
||||
pub mod session_mixer;
|
||||
pub mod windows_abi;
|
||||
pub mod windows_version;
|
||||
@@ -0,0 +1,299 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessEntry {
|
||||
pub pid: u32,
|
||||
pub parent: u32,
|
||||
}
|
||||
|
||||
fn find_process(entries: &[ProcessEntry], pid: u32) -> Option<ProcessEntry> {
|
||||
entries.iter().copied().find(|entry| entry.pid == pid)
|
||||
}
|
||||
|
||||
pub fn pid_is_our_descendant(entries: &[ProcessEntry], target: u32, self_pid: u32) -> bool {
|
||||
if target == 0 {
|
||||
return false;
|
||||
}
|
||||
if target == self_pid {
|
||||
return true;
|
||||
}
|
||||
let mut current = target;
|
||||
for _ in 0..64 {
|
||||
let Some(entry) = find_process(entries, current) else {
|
||||
return false;
|
||||
};
|
||||
if entry.parent == 0 || entry.parent == current {
|
||||
return false;
|
||||
}
|
||||
if entry.parent == self_pid {
|
||||
return true;
|
||||
}
|
||||
current = entry.parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn pid_is_our_ancestor(entries: &[ProcessEntry], target: u32, self_pid: u32) -> bool {
|
||||
if target == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut current = self_pid;
|
||||
for _ in 0..64 {
|
||||
if current == target {
|
||||
return true;
|
||||
}
|
||||
let Some(entry) = find_process(entries, current) else {
|
||||
return false;
|
||||
};
|
||||
if entry.parent == 0 || entry.parent == current {
|
||||
return false;
|
||||
}
|
||||
current = entry.parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn pid_overlaps_our_process_tree(entries: &[ProcessEntry], target: u32, self_pid: u32) -> bool {
|
||||
pid_is_our_descendant(entries, target, self_pid)
|
||||
|| pid_is_our_ancestor(entries, target, self_pid)
|
||||
}
|
||||
|
||||
pub fn deduplicate_capture_roots(
|
||||
entries: &[ProcessEntry],
|
||||
capture_pids: &[u32],
|
||||
exclude_pids: &[u32],
|
||||
) -> Vec<u32> {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let mut uncaptured: BTreeSet<u32> = capture_pids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|pid| *pid != 0)
|
||||
.collect();
|
||||
let excluded: BTreeSet<u32> = exclude_pids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|pid| *pid != 0)
|
||||
.collect();
|
||||
uncaptured.retain(|candidate| {
|
||||
!excluded
|
||||
.iter()
|
||||
.any(|excluded_pid| pid_is_our_descendant(entries, *excluded_pid, *candidate))
|
||||
});
|
||||
|
||||
let mut explicit = BTreeSet::new();
|
||||
while !uncaptured.is_empty() {
|
||||
let before = uncaptured.len();
|
||||
for pid in uncaptured.clone() {
|
||||
let parent = find_process(entries, pid)
|
||||
.map(|entry| entry.parent)
|
||||
.unwrap_or(0);
|
||||
if !uncaptured.contains(&parent) {
|
||||
explicit.insert(pid);
|
||||
}
|
||||
}
|
||||
for pid in &explicit {
|
||||
uncaptured.remove(pid);
|
||||
}
|
||||
for pid in uncaptured.clone() {
|
||||
let parent = find_process(entries, pid)
|
||||
.map(|entry| entry.parent)
|
||||
.unwrap_or(0);
|
||||
if explicit.contains(&parent) {
|
||||
uncaptured.remove(&pid);
|
||||
}
|
||||
}
|
||||
if uncaptured.len() == before {
|
||||
explicit.extend(uncaptured.iter().copied());
|
||||
break;
|
||||
}
|
||||
}
|
||||
explicit.into_iter().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn self_pid_is_always_its_own_descendant() {
|
||||
assert!(pid_is_our_descendant(&[], 1234, 1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_of_zero_is_never_matched() {
|
||||
assert!(!pid_is_our_descendant(&[], 0, 0));
|
||||
assert!(!pid_is_our_descendant(&[], 0, 1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_child_of_self_pid_is_matched() {
|
||||
let entries = [ProcessEntry {
|
||||
pid: 4001,
|
||||
parent: 1234,
|
||||
}];
|
||||
assert!(pid_is_our_descendant(&entries, 4001, 1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitive_descendant_via_two_hops_is_matched() {
|
||||
let entries = [
|
||||
ProcessEntry {
|
||||
pid: 4001,
|
||||
parent: 1234,
|
||||
},
|
||||
ProcessEntry {
|
||||
pid: 4099,
|
||||
parent: 4001,
|
||||
},
|
||||
];
|
||||
assert!(pid_is_our_descendant(&entries, 4099, 1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_of_self_pid_is_matched() {
|
||||
let entries = [
|
||||
ProcessEntry { pid: 10, parent: 1 },
|
||||
ProcessEntry {
|
||||
pid: 1234,
|
||||
parent: 10,
|
||||
},
|
||||
];
|
||||
assert!(pid_is_our_ancestor(&entries, 10, 1234));
|
||||
assert!(pid_overlaps_our_process_tree(&entries, 10, 1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicate_capture_roots_keeps_parent_and_drops_child() {
|
||||
let entries = [
|
||||
ProcessEntry { pid: 10, parent: 1 },
|
||||
ProcessEntry {
|
||||
pid: 11,
|
||||
parent: 10,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
deduplicate_capture_roots(&entries, &[10, 11], &[]),
|
||||
vec![10]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicate_capture_roots_drops_parent_of_excluded_child() {
|
||||
let entries = [
|
||||
ProcessEntry { pid: 10, parent: 1 },
|
||||
ProcessEntry {
|
||||
pid: 11,
|
||||
parent: 10,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
deduplicate_capture_roots(&entries, &[10], &[11]),
|
||||
Vec::<u32>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicate_capture_roots_drops_grandparent_of_excluded_grandchild() {
|
||||
let entries = [
|
||||
ProcessEntry { pid: 10, parent: 1 },
|
||||
ProcessEntry {
|
||||
pid: 11,
|
||||
parent: 10,
|
||||
},
|
||||
ProcessEntry {
|
||||
pid: 12,
|
||||
parent: 11,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
deduplicate_capture_roots(&entries, &[10], &[12]),
|
||||
Vec::<u32>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicate_capture_roots_keeps_safe_sibling_after_excluding_parent_root() {
|
||||
let entries = [
|
||||
ProcessEntry { pid: 10, parent: 1 },
|
||||
ProcessEntry {
|
||||
pid: 11,
|
||||
parent: 10,
|
||||
},
|
||||
ProcessEntry {
|
||||
pid: 12,
|
||||
parent: 10,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
deduplicate_capture_roots(&entries, &[10, 12], &[11]),
|
||||
vec![12]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicate_capture_roots_drops_excluded_root_itself() {
|
||||
let entries = [ProcessEntry { pid: 10, parent: 1 }];
|
||||
assert_eq!(
|
||||
deduplicate_capture_roots(&entries, &[10], &[10]),
|
||||
Vec::<u32>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicate_capture_roots_ignores_zero_pids() {
|
||||
let entries = [ProcessEntry { pid: 10, parent: 1 }];
|
||||
assert_eq!(
|
||||
deduplicate_capture_roots(&entries, &[0, 10], &[0]),
|
||||
vec![10]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicate_capture_roots_keeps_pid_when_parent_is_missing() {
|
||||
let entries = [ProcessEntry {
|
||||
pid: 10,
|
||||
parent: 9999,
|
||||
}];
|
||||
assert_eq!(deduplicate_capture_roots(&entries, &[10], &[]), vec![10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_pid_in_same_snapshot_is_not_matched() {
|
||||
let entries = [
|
||||
ProcessEntry {
|
||||
pid: 4001,
|
||||
parent: 1234,
|
||||
},
|
||||
ProcessEntry {
|
||||
pid: 8500,
|
||||
parent: 1,
|
||||
},
|
||||
];
|
||||
assert!(!pid_is_our_descendant(&entries, 8500, 1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_terminates_the_walk_safely() {
|
||||
let entries = [ProcessEntry {
|
||||
pid: 9000,
|
||||
parent: 9999,
|
||||
}];
|
||||
assert!(!pid_is_our_descendant(&entries, 9000, 1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ppid_equal_to_self_loop_is_rejected() {
|
||||
let entries = [ProcessEntry {
|
||||
pid: 5000,
|
||||
parent: 5000,
|
||||
}];
|
||||
assert!(!pid_is_our_descendant(&entries, 5000, 1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ppid_zero_terminates_the_walk() {
|
||||
let entries = [ProcessEntry { pid: 4, parent: 0 }];
|
||||
assert!(!pid_is_our_descendant(&entries, 4, 1234));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MixerPacket {
|
||||
pub timestamp_us: i64,
|
||||
pub samples: Vec<f32>,
|
||||
}
|
||||
|
||||
pub fn timestamp_delta_to_frames(
|
||||
base_timestamp_us: i64,
|
||||
timestamp_us: i64,
|
||||
sample_rate: u32,
|
||||
) -> usize {
|
||||
if sample_rate == 0 {
|
||||
return 0;
|
||||
}
|
||||
let delta_us = timestamp_us.saturating_sub(base_timestamp_us).max(0) as u128;
|
||||
((delta_us * u128::from(sample_rate)) / 1_000_000) as usize
|
||||
}
|
||||
|
||||
fn normalize_packet_to_whole_frames(
|
||||
mut packet: MixerPacket,
|
||||
channel_count: usize,
|
||||
) -> Option<MixerPacket> {
|
||||
if channel_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let whole = (packet.samples.len() / channel_count) * channel_count;
|
||||
if whole == 0 {
|
||||
return None;
|
||||
}
|
||||
packet.samples.truncate(whole);
|
||||
for sample in &mut packet.samples {
|
||||
*sample = (*sample).clamp(-1.0, 1.0);
|
||||
}
|
||||
Some(packet)
|
||||
}
|
||||
|
||||
pub fn mix_packets(
|
||||
mut packets: Vec<MixerPacket>,
|
||||
channels: u16,
|
||||
sample_rate: u32,
|
||||
max_emit_frames: usize,
|
||||
) -> Vec<MixerPacket> {
|
||||
let channel_count = usize::from(channels);
|
||||
if channel_count == 0 || sample_rate == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
packets.retain(|packet| packet.samples.len() >= channel_count);
|
||||
if packets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
packets.sort_by_key(|packet| packet.timestamp_us);
|
||||
let base_timestamp = packets[0].timestamp_us;
|
||||
let mut output_frames = 0usize;
|
||||
for packet in &packets {
|
||||
let offset_frames =
|
||||
timestamp_delta_to_frames(base_timestamp, packet.timestamp_us, sample_rate);
|
||||
let frames = packet.samples.len() / channel_count;
|
||||
output_frames = output_frames.max(offset_frames.saturating_add(frames));
|
||||
}
|
||||
if output_frames == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if output_frames > max_emit_frames {
|
||||
return packets
|
||||
.into_iter()
|
||||
.filter_map(|packet| normalize_packet_to_whole_frames(packet, channel_count))
|
||||
.collect();
|
||||
}
|
||||
let mut mixed = vec![0.0f32; output_frames * channel_count];
|
||||
for packet in packets {
|
||||
let offset = timestamp_delta_to_frames(base_timestamp, packet.timestamp_us, sample_rate)
|
||||
* channel_count;
|
||||
let full_frame_samples = (packet.samples.len() / channel_count) * channel_count;
|
||||
for (idx, sample) in packet
|
||||
.samples
|
||||
.iter()
|
||||
.copied()
|
||||
.take(full_frame_samples)
|
||||
.enumerate()
|
||||
{
|
||||
if let Some(slot) = mixed.get_mut(offset + idx) {
|
||||
*slot += sample;
|
||||
}
|
||||
}
|
||||
}
|
||||
for sample in &mut mixed {
|
||||
*sample = (*sample).clamp(-1.0, 1.0);
|
||||
}
|
||||
vec![MixerPacket {
|
||||
timestamp_us: base_timestamp,
|
||||
samples: mixed,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn packet(timestamp_us: i64, samples: &[f32]) -> MixerPacket {
|
||||
MixerPacket {
|
||||
timestamp_us,
|
||||
samples: samples.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_delta_to_frames_uses_audio_rate() {
|
||||
assert_eq!(0, timestamp_delta_to_frames(1_000, 1_000, 48_000));
|
||||
assert_eq!(48, timestamp_delta_to_frames(1_000, 2_000, 48_000));
|
||||
assert_eq!(0, timestamp_delta_to_frames(2_000, 1_000, 48_000));
|
||||
assert_eq!(0, timestamp_delta_to_frames(1_000, 2_000, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_ignores_empty_input() {
|
||||
assert!(mix_packets(Vec::new(), 2, 48_000, 24_000).is_empty());
|
||||
assert!(mix_packets(vec![packet(0, &[])], 2, 48_000, 24_000).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_rejects_invalid_format_shape() {
|
||||
assert!(mix_packets(vec![packet(0, &[1.0, 1.0])], 0, 48_000, 24_000).is_empty());
|
||||
assert!(mix_packets(vec![packet(0, &[1.0, 1.0])], 2, 0, 24_000).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_sums_packets_with_matching_timestamps() {
|
||||
let mixed = mix_packets(
|
||||
vec![packet(10, &[0.25, 0.5]), packet(10, &[0.25, -0.25])],
|
||||
2,
|
||||
48_000,
|
||||
24_000,
|
||||
);
|
||||
assert_eq!(mixed, vec![packet(10, &[0.5, 0.25])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_clamps_overlapping_samples() {
|
||||
let mixed = mix_packets(
|
||||
vec![packet(10, &[0.75, -0.75]), packet(10, &[0.75, -0.75])],
|
||||
2,
|
||||
48_000,
|
||||
24_000,
|
||||
);
|
||||
assert_eq!(mixed, vec![packet(10, &[1.0, -1.0])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_offsets_by_timestamp() {
|
||||
let mixed = mix_packets(
|
||||
vec![packet(1_000, &[1.0, 0.0]), packet(2_000, &[0.0, 1.0])],
|
||||
2,
|
||||
48_000,
|
||||
24_000,
|
||||
);
|
||||
assert_eq!(mixed.len(), 1);
|
||||
assert_eq!(mixed[0].timestamp_us, 1_000);
|
||||
assert_eq!(&mixed[0].samples[0..2], &[1.0, 0.0]);
|
||||
assert_eq!(&mixed[0].samples[96..98], &[0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_sorts_packets_before_mixing() {
|
||||
let mixed = mix_packets(
|
||||
vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[1.0, 0.0])],
|
||||
2,
|
||||
48_000,
|
||||
24_000,
|
||||
);
|
||||
assert_eq!(mixed.len(), 1);
|
||||
assert_eq!(mixed[0].timestamp_us, 1_000);
|
||||
assert_eq!(&mixed[0].samples[0..2], &[1.0, 0.0]);
|
||||
assert_eq!(&mixed[0].samples[96..98], &[0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_falls_back_to_sorted_packets_when_span_is_too_large() {
|
||||
let packets = vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[1.0, 0.0])];
|
||||
let mixed = mix_packets(packets, 2, 48_000, 1);
|
||||
assert_eq!(
|
||||
mixed,
|
||||
vec![packet(1_000, &[1.0, 0.0]), packet(2_000, &[0.0, 1.0])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_fallback_truncates_partial_frames_to_whole_stereo() {
|
||||
let packets = vec![
|
||||
packet(2_000, &[0.0, 1.0, 0.5]),
|
||||
packet(1_000, &[1.0, 0.0, 0.25, 0.75]),
|
||||
];
|
||||
let mixed = mix_packets(packets, 2, 48_000, 1);
|
||||
assert_eq!(
|
||||
mixed,
|
||||
vec![
|
||||
packet(1_000, &[1.0, 0.0, 0.25, 0.75]),
|
||||
packet(2_000, &[0.0, 1.0]),
|
||||
]
|
||||
);
|
||||
for emitted in &mixed {
|
||||
assert_eq!(emitted.samples.len() % 2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_fallback_clamps_out_of_range_samples() {
|
||||
let packets = vec![
|
||||
packet(2_000, &[0.0, 1.0]),
|
||||
packet(1_000, &[4.0, -4.0, 0.5, -0.5]),
|
||||
];
|
||||
let mixed = mix_packets(packets, 2, 48_000, 1);
|
||||
assert_eq!(
|
||||
mixed,
|
||||
vec![
|
||||
packet(1_000, &[1.0, -1.0, 0.5, -0.5]),
|
||||
packet(2_000, &[0.0, 1.0]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_fallback_drops_subframe_packets() {
|
||||
let packets = vec![packet(2_000, &[0.0, 1.0]), packet(1_000, &[0.5])];
|
||||
let mixed = mix_packets(packets, 2, 48_000, 1);
|
||||
assert_eq!(mixed, vec![packet(2_000, &[0.0, 1.0])]);
|
||||
for emitted in &mixed {
|
||||
assert_eq!(emitted.samples.len() % 2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_packets_truncates_partial_trailing_frames() {
|
||||
let mixed = mix_packets(vec![packet(10, &[0.25, 0.5, 0.75])], 2, 48_000, 24_000);
|
||||
assert_eq!(mixed, vec![packet(10, &[0.25, 0.5])]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
pub type Bool = i32;
|
||||
pub type Byte = u8;
|
||||
pub type Dword = u32;
|
||||
pub type Handle = *mut c_void;
|
||||
pub type Hresult = i32;
|
||||
pub type Long = i32;
|
||||
pub type LargeInteger = i64;
|
||||
pub type ReferenceTime = i64;
|
||||
pub type Uint = u32;
|
||||
pub type Ulong = u32;
|
||||
pub type Word = u16;
|
||||
pub type Wchar = u16;
|
||||
|
||||
pub const FALSE: Bool = 0;
|
||||
pub const TRUE: Bool = 1;
|
||||
pub const INFINITE: Dword = 0xffff_ffff;
|
||||
pub const INVALID_HANDLE_VALUE: Handle = usize::MAX as Handle;
|
||||
pub const WAIT_FAILED: Dword = 0xffff_ffff;
|
||||
pub const WAIT_OBJECT_0: Dword = 0;
|
||||
|
||||
pub const S_OK: Hresult = 0;
|
||||
pub const E_NOINTERFACE: Hresult = 0x8000_4002_u32 as Hresult;
|
||||
|
||||
pub const COINIT_MULTITHREADED: Dword = 0;
|
||||
pub const TH32CS_SNAPPROCESS: Dword = 0x0000_0002;
|
||||
pub const VT_BLOB: Word = 65;
|
||||
pub const WAVE_FORMAT_IEEE_FLOAT: Word = 3;
|
||||
pub const WAVE_FORMAT_EXTENSIBLE: Word = 0xfffe;
|
||||
pub const SPEAKER_FRONT_LEFT: Dword = 0x1;
|
||||
pub const SPEAKER_FRONT_RIGHT: Dword = 0x2;
|
||||
pub const KSAUDIO_SPEAKER_STEREO: Dword = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT;
|
||||
|
||||
pub const AUDCLNT_BUFFERFLAGS_SILENT: Dword = 0x0000_0002;
|
||||
pub const AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR: Dword = 0x0000_0004;
|
||||
pub const AUDCLNT_SHAREMODE_SHARED: AudioClientShareMode = 0;
|
||||
pub const AUDCLNT_STREAMFLAGS_LOOPBACK: Dword = 0x0002_0000;
|
||||
pub const AUDCLNT_STREAMFLAGS_EVENTCALLBACK: Dword = 0x0004_0000;
|
||||
pub const AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM: Dword = 0x8000_0000;
|
||||
|
||||
pub type AudioClientActivationType = i32;
|
||||
pub const AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK: AudioClientActivationType = 1;
|
||||
pub type ProcessLoopbackMode = i32;
|
||||
pub const PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE: ProcessLoopbackMode = 0;
|
||||
pub const PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE: ProcessLoopbackMode = 1;
|
||||
pub type AudioClientShareMode = i32;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Guid {
|
||||
pub data1: u32,
|
||||
pub data2: u16,
|
||||
pub data3: u16,
|
||||
pub data4: [u8; 8],
|
||||
}
|
||||
|
||||
pub type Iid = Guid;
|
||||
|
||||
pub const IID_IUNKNOWN: Guid = Guid {
|
||||
data1: 0x0000_0000,
|
||||
data2: 0x0000,
|
||||
data3: 0x0000,
|
||||
data4: [0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46],
|
||||
};
|
||||
|
||||
pub const IID_IAGILE_OBJECT: Guid = Guid {
|
||||
data1: 0x94ea_2b94,
|
||||
data2: 0xe9cc,
|
||||
data3: 0x49e0,
|
||||
data4: [0xc0, 0xff, 0xee, 0x64, 0xca, 0x8f, 0x5b, 0x90],
|
||||
};
|
||||
|
||||
pub const IID_IMARSHAL: Guid = Guid {
|
||||
data1: 0x0000_0003,
|
||||
data2: 0x0000,
|
||||
data3: 0x0000,
|
||||
data4: [0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46],
|
||||
};
|
||||
|
||||
pub const IID_IAUDIO_CLIENT: Guid = Guid {
|
||||
data1: 0x1cb9_ad4c,
|
||||
data2: 0xdbfa,
|
||||
data3: 0x4c32,
|
||||
data4: [0xb1, 0x78, 0xc2, 0xf5, 0x68, 0xa7, 0x03, 0xb2],
|
||||
};
|
||||
|
||||
pub const IID_IAUDIO_CAPTURE_CLIENT: Guid = Guid {
|
||||
data1: 0xc8ad_bd64,
|
||||
data2: 0xe71e,
|
||||
data3: 0x48a0,
|
||||
data4: [0xa4, 0xde, 0x18, 0x5c, 0x39, 0x5c, 0xd3, 0x17],
|
||||
};
|
||||
|
||||
pub const KSDATAFORMAT_SUBTYPE_IEEE_FLOAT: Guid = Guid {
|
||||
data1: 0x0000_0003,
|
||||
data2: 0x0000,
|
||||
data3: 0x0010,
|
||||
data4: [0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71],
|
||||
};
|
||||
|
||||
pub const IID_IACTIVATE_AUDIO_INTERFACE_COMPLETION_HANDLER: Guid = Guid {
|
||||
data1: 0x41d9_49ab,
|
||||
data2: 0x9862,
|
||||
data3: 0x444a,
|
||||
data4: [0x80, 0xf6, 0xc2, 0x61, 0x33, 0x4d, 0xa5, 0xeb],
|
||||
};
|
||||
|
||||
pub const VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK: &[u16] = &[
|
||||
'V' as u16,
|
||||
'A' as u16,
|
||||
'D' as u16,
|
||||
'\\' as u16,
|
||||
'P' as u16,
|
||||
'r' as u16,
|
||||
'o' as u16,
|
||||
'c' as u16,
|
||||
'e' as u16,
|
||||
's' as u16,
|
||||
's' as u16,
|
||||
'_' as u16,
|
||||
'L' as u16,
|
||||
'o' as u16,
|
||||
'o' as u16,
|
||||
'p' as u16,
|
||||
'b' as u16,
|
||||
'a' as u16,
|
||||
'c' as u16,
|
||||
'k' as u16,
|
||||
0,
|
||||
];
|
||||
|
||||
#[repr(C, packed(1))]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct WaveFormatEx {
|
||||
pub w_format_tag: Word,
|
||||
pub n_channels: Word,
|
||||
pub n_samples_per_sec: Dword,
|
||||
pub n_avg_bytes_per_sec: Dword,
|
||||
pub n_block_align: Word,
|
||||
pub w_bits_per_sample: Word,
|
||||
pub cb_size: Word,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub union WaveFormatSamples {
|
||||
pub w_valid_bits_per_sample: Word,
|
||||
pub w_samples_per_block: Word,
|
||||
pub w_reserved: Word,
|
||||
}
|
||||
|
||||
#[repr(C, packed(1))]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct WaveFormatExtensible {
|
||||
pub format: WaveFormatEx,
|
||||
pub samples: WaveFormatSamples,
|
||||
pub dw_channel_mask: Dword,
|
||||
pub sub_format: Guid,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AudioClientProcessLoopbackParams {
|
||||
pub target_process_id: Dword,
|
||||
pub process_loopback_mode: ProcessLoopbackMode,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub union AudioClientActivationParamsAnonymous {
|
||||
pub process_loopback_params: AudioClientProcessLoopbackParams,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct AudioClientActivationParams {
|
||||
pub activation_type: AudioClientActivationType,
|
||||
pub anonymous: AudioClientActivationParamsAnonymous,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Blob {
|
||||
pub cb_size: Ulong,
|
||||
pub p_blob_data: *mut Byte,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PropVariant {
|
||||
pub vt: Word,
|
||||
pub w_reserved1: Word,
|
||||
pub w_reserved2: Word,
|
||||
pub w_reserved3: Word,
|
||||
pub blob: Blob,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ProcessEntry32W {
|
||||
pub dw_size: Dword,
|
||||
pub cnt_usage: Dword,
|
||||
pub th32_process_id: Dword,
|
||||
pub th32_default_heap_id: usize,
|
||||
pub th32_module_id: Dword,
|
||||
pub cnt_threads: Dword,
|
||||
pub th32_parent_process_id: Dword,
|
||||
pub pc_pri_class_base: Long,
|
||||
pub dw_flags: Dword,
|
||||
pub sz_exe_file: [Wchar; 260],
|
||||
}
|
||||
|
||||
pub type NapiEnv = *mut c_void;
|
||||
pub type NapiValue = *mut c_void;
|
||||
pub type NapiCallbackInfo = *mut c_void;
|
||||
pub type NapiThreadsafeFunction = *mut c_void;
|
||||
pub type NapiStatus = i32;
|
||||
pub type NapiValueType = i32;
|
||||
pub type NapiTypedArrayType = i32;
|
||||
pub type NapiThreadsafeFunctionReleaseMode = i32;
|
||||
pub type NapiThreadsafeFunctionCallMode = i32;
|
||||
pub type NapiPropertyAttributes = i32;
|
||||
|
||||
pub const NAPI_OK: NapiStatus = 0;
|
||||
pub const NAPI_UNDEFINED: NapiValueType = 0;
|
||||
pub const NAPI_NULL: NapiValueType = 1;
|
||||
pub const NAPI_BOOLEAN: NapiValueType = 2;
|
||||
pub const NAPI_NUMBER: NapiValueType = 3;
|
||||
pub const NAPI_STRING: NapiValueType = 4;
|
||||
pub const NAPI_FLOAT32_ARRAY: NapiTypedArrayType = 6;
|
||||
pub const NAPI_DEFAULT_METHOD: NapiPropertyAttributes = 5;
|
||||
pub const NAPI_TSFN_NONBLOCKING: NapiThreadsafeFunctionCallMode = 0;
|
||||
pub const NAPI_TSFN_RELEASE: NapiThreadsafeFunctionReleaseMode = 0;
|
||||
pub const NAPI_TSFN_ABORT: NapiThreadsafeFunctionReleaseMode = 1;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wave_format_extensible_layout_matches_windows_abi() {
|
||||
assert_eq!(18, std::mem::size_of::<WaveFormatEx>());
|
||||
assert_eq!(40, std::mem::size_of::<WaveFormatExtensible>());
|
||||
assert_eq!(18, std::mem::offset_of!(WaveFormatExtensible, samples));
|
||||
assert_eq!(
|
||||
20,
|
||||
std::mem::offset_of!(WaveFormatExtensible, dw_channel_mask)
|
||||
);
|
||||
assert_eq!(24, std::mem::offset_of!(WaveFormatExtensible, sub_format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_float_extensible_constants_match_ksmedia_h() {
|
||||
assert_eq!(0xfffe, WAVE_FORMAT_EXTENSIBLE);
|
||||
assert_eq!(0x3, KSAUDIO_SPEAKER_STEREO);
|
||||
assert_eq!(0x4, AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR);
|
||||
assert_eq!(0x0000_0003, KSDATAFORMAT_SUBTYPE_IEEE_FLOAT.data1);
|
||||
assert_eq!(0x0000, KSDATAFORMAT_SUBTYPE_IEEE_FLOAT.data2);
|
||||
assert_eq!(0x0010, KSDATAFORMAT_SUBTYPE_IEEE_FLOAT.data3);
|
||||
assert_eq!(
|
||||
[0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71],
|
||||
KSDATAFORMAT_SUBTYPE_IEEE_FLOAT.data4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iagile_object_iid_matches_objidlbase_h() {
|
||||
assert_eq!(0x94ea_2b94, IID_IAGILE_OBJECT.data1);
|
||||
assert_eq!(0xe9cc, IID_IAGILE_OBJECT.data2);
|
||||
assert_eq!(0x49e0, IID_IAGILE_OBJECT.data3);
|
||||
assert_eq!(
|
||||
[0xc0, 0xff, 0xee, 0x64, 0xca, 0x8f, 0x5b, 0x90],
|
||||
IID_IAGILE_OBJECT.data4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imarshal_iid_matches_objidlbase_h() {
|
||||
assert_eq!(0x0000_0003, IID_IMARSHAL.data1);
|
||||
assert_eq!(0x0000, IID_IMARSHAL.data2);
|
||||
assert_eq!(0x0000, IID_IMARSHAL.data3);
|
||||
assert_eq!(
|
||||
[0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46],
|
||||
IID_IMARSHAL.data4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_api_value_type_constants_match_node_api_h() {
|
||||
assert_eq!(0, NAPI_UNDEFINED);
|
||||
assert_eq!(1, NAPI_NULL);
|
||||
assert_eq!(2, NAPI_BOOLEAN);
|
||||
assert_eq!(3, NAPI_NUMBER);
|
||||
assert_eq!(4, NAPI_STRING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_loopback_device_id_is_utf16_null_terminated() {
|
||||
assert_eq!(Some(&0), VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK.last());
|
||||
let without_nul = &VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK
|
||||
[..VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK.len() - 1];
|
||||
assert_eq!(
|
||||
"VAD\\Process_Loopback",
|
||||
String::from_utf16(without_nul).expect("utf16")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const PROCESS_LOOPBACK_MIN_BUILD: u32 = 20_348;
|
||||
|
||||
pub fn supports_process_loopback(major: u32, minor: u32, build: u32) -> bool {
|
||||
if major > 10 {
|
||||
return true;
|
||||
}
|
||||
major == 10 && minor == 0 && build >= PROCESS_LOOPBACK_MIN_BUILD
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn process_loopback_follows_microsoft_documented_windows_build_floor() {
|
||||
assert!(!supports_process_loopback(10, 0, 19_045));
|
||||
assert!(!supports_process_loopback(
|
||||
10,
|
||||
0,
|
||||
PROCESS_LOOPBACK_MIN_BUILD - 1
|
||||
));
|
||||
assert!(supports_process_loopback(10, 0, PROCESS_LOOPBACK_MIN_BUILD));
|
||||
assert!(supports_process_loopback(10, 0, 22_000));
|
||||
assert!(supports_process_loopback(11, 0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_desktop_native::audio::contract::{
|
||||
DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_MAX_READ_SAMPLES, bounded_direct_read_sample_count,
|
||||
direct_whole_frame_sample_count, whole_frame_sample_count,
|
||||
};
|
||||
use fluxer_desktop_native::input::ring::Ring;
|
||||
use fluxer_desktop_native::linux_audio::routing::{
|
||||
MEDIA_CLASS_PLAYBACK_STREAM, RoutingRule, SelfIdentity, map, matches_pattern, should_route_node,
|
||||
};
|
||||
use fluxer_desktop_native::linux_evdev::event::{InputEvent, parse_input_event};
|
||||
use fluxer_desktop_native::linux_portals::pid_payload::parse_shell_eval_pid_payload;
|
||||
use fluxer_desktop_native::mac_app_audio::process_tree::{
|
||||
Info, collect_related_pids_with_resolver, is_same_launch_tree_with_resolver,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn input_event_bytes(event: InputEvent) -> [u8; InputEvent::BYTE_LEN] {
|
||||
let mut out = [0_u8; InputEvent::BYTE_LEN];
|
||||
out[0..8].copy_from_slice(&event.time_sec.to_ne_bytes());
|
||||
out[8..16].copy_from_slice(&event.time_usec.to_ne_bytes());
|
||||
out[16..18].copy_from_slice(&event.event_type.to_ne_bytes());
|
||||
out[18..20].copy_from_slice(&event.code.to_ne_bytes());
|
||||
out[20..24].copy_from_slice(&event.value.to_ne_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn whole_frame_count_never_exceeds_input_and_is_channel_aligned(sample_count in 0usize..1_000_000, channels in 0u32..16) {
|
||||
let count = whole_frame_sample_count(sample_count, channels);
|
||||
prop_assert!(count <= sample_count);
|
||||
if channels == 0 {
|
||||
prop_assert_eq!(0, count);
|
||||
} else {
|
||||
prop_assert_eq!(0, count % channels as usize);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_read_bound_is_stereo_aligned_and_capped(available in 0usize..1_000_000) {
|
||||
let count = bounded_direct_read_sample_count(available);
|
||||
prop_assert!(count <= DIRECT_CAPTURE_MAX_READ_SAMPLES);
|
||||
prop_assert_eq!(0, count % DIRECT_CAPTURE_CHANNELS as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_payload_returns_positive_u32_digit_runs(prefix in "[A-Za-z_\\[\\], ]*", pid in 1u32..u32::MAX, suffix in "[A-Za-z_\\[\\], ]*") {
|
||||
let payload = format!("{prefix}{pid}{suffix}");
|
||||
prop_assert_eq!(Some(pid), parse_shell_eval_pid_payload(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_pattern_matching_is_subset_exact(key in "[a-z.]{1,32}", value in "[a-z0-9_-]{1,32}", other in "[a-z0-9_-]{1,32}") {
|
||||
let candidate = map(&[(&key, &value)]);
|
||||
let matching = map(&[(&key, &value)]);
|
||||
prop_assert!(matches_pattern(&candidate, &matching));
|
||||
if other != value {
|
||||
let mismatched = map(&[(&key, &other)]);
|
||||
prop_assert!(!matches_pattern(&candidate, &mismatched));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evdev_input_event_parser_round_trips_native_endian_fields(
|
||||
time_sec in any::<i64>(),
|
||||
time_usec in any::<i64>(),
|
||||
event_type in any::<u16>(),
|
||||
code in any::<u16>(),
|
||||
value in any::<i32>(),
|
||||
) {
|
||||
let event = InputEvent {
|
||||
time_sec,
|
||||
time_usec,
|
||||
event_type,
|
||||
code,
|
||||
value,
|
||||
};
|
||||
prop_assert_eq!(Some(event), parse_input_event(&input_event_bytes(event)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_process_tree_direct_children_are_collected(target in 2i32..100_000, child_delta in 1i32..1000) {
|
||||
let child = target + child_delta;
|
||||
let infos = [
|
||||
Info { pid: target, parent_pid: 1, process_group_id: target },
|
||||
Info { pid: child, parent_pid: target, process_group_id: target },
|
||||
];
|
||||
let resolver = |pid| infos.iter().copied().find(|info| info.pid == pid);
|
||||
prop_assert!(is_same_launch_tree_with_resolver(child, target, Some(infos[0]), resolver));
|
||||
let resolver = |pid| infos.iter().copied().find(|info| info.pid == pid);
|
||||
prop_assert_eq!(
|
||||
vec![target, child],
|
||||
collect_related_pids_with_resolver(target, Some(infos[0]), &[child], 4, resolver)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_stress_preserves_fifo_under_repeated_fill_drain_cycles() {
|
||||
let mut ring: Ring<u64, 1024> = Ring::new();
|
||||
for cycle in 0..512_u64 {
|
||||
for index in 0..1024_u64 {
|
||||
let slot = ring.claim().expect("slot") as usize;
|
||||
ring.slots[slot] = cycle * 10_000 + index;
|
||||
}
|
||||
assert!(ring.claim().is_none());
|
||||
for index in 0..1024_u64 {
|
||||
let slot = ring.pop().expect("slot") as usize;
|
||||
assert_eq!(cycle * 10_000 + index, ring.slots[slot]);
|
||||
ring.release();
|
||||
}
|
||||
assert!(ring.pop().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_identity_always_beats_user_include_rules() {
|
||||
let mut self_identity = SelfIdentity::default();
|
||||
self_identity.add_pid("42");
|
||||
self_identity.add_binary("fluxer");
|
||||
self_identity.add_display_name("Fluxer Canary");
|
||||
self_identity.add_display_prefix("Fluxer ");
|
||||
let rule = RoutingRule {
|
||||
include_when: vec![
|
||||
map(&[("application.process.id", "42")]),
|
||||
map(&[("application.name", "fluxer")]),
|
||||
],
|
||||
..RoutingRule::default()
|
||||
};
|
||||
for props in [
|
||||
map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("application.process.id", "42"),
|
||||
]),
|
||||
map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("application.process.binary", "fluxer"),
|
||||
]),
|
||||
map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("application.name", "fluxer"),
|
||||
]),
|
||||
map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
("node.name", "fluxer"),
|
||||
]),
|
||||
map(&[
|
||||
("media.class", MEDIA_CLASS_PLAYBACK_STREAM),
|
||||
(
|
||||
"node.description",
|
||||
"Fluxer desktop audio tap excluding pid 42",
|
||||
),
|
||||
]),
|
||||
] {
|
||||
assert!(!should_route_node(
|
||||
1,
|
||||
&props,
|
||||
&rule,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
&self_identity
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_whole_frame_count_is_same_as_generic_stereo_helper() {
|
||||
for value in 0..10_000 {
|
||||
assert_eq!(
|
||||
whole_frame_sample_count(value, 2),
|
||||
direct_whole_frame_sample_count(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user