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:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
@@ -0,0 +1,863 @@
#![allow(clippy::too_many_lines)]
// SPDX-License-Identifier: AGPL-3.0-or-later
mod audio_contract;
mod audio_mix_runtime;
mod backend;
mod direct_buffer;
mod ignore_audio_runtime;
#[doc(hidden)]
pub mod audio_mix_runtime_bench_helpers {
pub use crate::audio_mix_runtime::{
AudioMixRuntime, AudioMixRuntimeBuilder, CaptureSource, CapturedMixOutputSink,
MIX_CHANNELS, MIX_SAMPLE_RATE_HZ, MIX_TICK_PERIOD_NS, MixOutputFrame, MixOutputSink,
MixRuntimeError, NullMixOutputSink, SOURCE_RING_CAP_FRAMES,
};
}
#[doc(hidden)]
pub mod ignore_audio_bench_helpers {
pub use crate::ignore_audio_runtime::{
AUDIO_BUFFERING_MAX_TICKS, IgnoreAudioDecision, IgnoreAudioEvaluation, IgnoreAudioMetrics,
IgnoreAudioPolicy, IgnoreAudioResetReason, IgnoreAudioSourceResetEvent,
IgnoreAudioSourceState, IgnoreAudioTick, SOURCE_RESET_AFTER_BUFFERED_TICKS,
SOURCE_STALE_AFTER_NS,
};
}
#[cfg(target_os = "linux")]
mod pipewire;
#[cfg(target_os = "linux")]
mod pipewire_bridge;
mod routing;
#[cfg(target_os = "linux")]
mod self_identity;
use std::ptr;
use std::sync::Arc;
use std::sync::Mutex;
use fluxer_screen_frame_bus::{NativeScreenFrameSinkHandle, NativeScreenFrameSinkHandleRef};
use napi::Env;
use napi::JsValue;
use napi::Status;
use napi::bindgen_prelude::{
Array, ArrayBuffer, Error, Function, Object, Result, Unknown, ValueType,
};
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use napi_derive::napi;
use crate::audio_contract::{
MAX_INVENTORY_FIELD_LENGTH, MAX_INVENTORY_FIELDS, MAX_ROUTING_RULE_KEY_LENGTH,
MAX_ROUTING_RULE_KEYS_PER_PATTERN, MAX_ROUTING_RULE_PATTERNS, MAX_ROUTING_RULE_VALUE_LENGTH,
};
use crate::backend::{
CaptureBridge as CaptureBridgeTrait, DirectCapture as DirectCaptureTrait, RoutingGraphSnapshot,
};
use crate::routing::{PropMap, PropPattern, RoutingRule, SelfIdentity};
type LifecycleTsfn =
Arc<ThreadsafeFunction<(String, String), (), (String, String), Status, false, false, 8>>;
#[cfg(target_os = "linux")]
fn make_self_identity() -> SelfIdentity {
let mut id = SelfIdentity::default();
self_identity::populate_self_identity(&mut id);
id
}
#[cfg(not(target_os = "linux"))]
#[allow(dead_code)]
fn make_self_identity() -> SelfIdentity {
SelfIdentity::default()
}
#[cfg(target_os = "linux")]
fn open_capture_backend() -> Option<(Box<dyn CaptureBridgeTrait>, &'static str)> {
if let Some(bridge) = pipewire_bridge::PipeWireBridge::open() {
bridge.populate_self_identity(make_self_identity());
return Some((Box::new(bridge), "pipewire"));
}
None
}
#[cfg(not(target_os = "linux"))]
fn open_capture_backend() -> Option<(Box<dyn CaptureBridgeTrait>, &'static str)> {
None
}
#[cfg(target_os = "linux")]
fn open_direct_backend() -> Option<Box<dyn DirectCaptureTrait>> {
if let Some(direct) = pipewire_bridge::PipeWireDirectCapture::open() {
direct.populate_self_identity(make_self_identity());
return Some(Box::new(direct));
}
None
}
#[cfg(not(target_os = "linux"))]
fn open_direct_backend() -> Option<Box<dyn DirectCaptureTrait>> {
None
}
#[cfg(target_os = "linux")]
fn pipewire_reachable() -> bool {
pipewire_bridge::daemon_reachable()
}
#[cfg(not(target_os = "linux"))]
fn pipewire_reachable() -> bool {
false
}
#[napi(js_name = "pipeWireAvailable")]
pub fn pipe_wire_available() -> bool {
pipewire_reachable()
}
#[napi(js_name = "audioBackend")]
pub fn audio_backend() -> &'static str {
if pipewire_reachable() {
"pipewire"
} else {
"none"
}
}
#[napi]
pub struct AudioBridge {
backend: Mutex<Option<Box<dyn CaptureBridgeTrait>>>,
name: &'static str,
}
#[napi]
impl AudioBridge {
#[napi(constructor)]
pub fn new() -> Self {
match open_capture_backend() {
Some((backend, name)) => Self {
backend: Mutex::new(Some(backend)),
name,
},
None => Self {
backend: Mutex::new(None),
name: "none",
},
}
}
#[napi]
pub fn inventory(&self, fields: Option<Vec<String>>) -> Result<Vec<PropMapWire>> {
let fields = match fields {
Some(values) => validate_inventory_fields(values)?,
None => Vec::new(),
};
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
let snapshot = guard.as_ref().map(|b| b.inventory()).unwrap_or_default();
Ok(snapshot
.into_iter()
.map(|entry| project_inventory_entry(entry, &fields))
.collect())
}
#[napi]
pub fn apply(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.apply(parsed)))
}
#[napi]
pub fn release(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.release();
}
Ok(())
}
#[napi(js_name = "routingGraph")]
pub fn routing_graph(&self) -> Result<RoutingGraphWire> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("AudioBridge backend poisoned"))?;
let graph = guard
.as_ref()
.map(|b| b.routing_graph())
.unwrap_or_default();
Ok(RoutingGraphWire(graph))
}
#[napi]
pub fn backend(&self) -> &'static str {
self.name
}
}
impl Default for AudioBridge {
fn default() -> Self {
Self::new()
}
}
fn retain_screen_audio_sink_handle(
value: Unknown<'_>,
) -> Result<Arc<NativeScreenFrameSinkHandleRef>> {
if value.get_type()? != ValueType::External {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink expects a native external sink handle",
));
}
let raw_value = value.value();
let mut data: *mut std::ffi::c_void = ptr::null_mut();
let status =
unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) };
if status != napi::sys::Status::napi_ok || data.is_null() {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink received an empty native external sink handle",
));
}
let handle = unsafe {
NativeScreenFrameSinkHandle::retain_from_raw(data.cast::<NativeScreenFrameSinkHandle>())
}
.ok_or_else(|| {
generic_error("DirectAudioCapture.setScreenAudioSink received an invalid handle")
})?;
Ok(Arc::new(handle))
}
#[napi]
pub struct DirectAudioCapture {
backend: Mutex<Option<Box<dyn DirectCaptureTrait>>>,
lifecycle_tsfn: Mutex<Option<LifecycleTsfn>>,
}
#[napi]
impl DirectAudioCapture {
#[napi(constructor)]
pub fn new() -> Self {
Self {
backend: Mutex::new(open_direct_backend()),
lifecycle_tsfn: Mutex::new(None),
}
}
#[napi(js_name = "setLifecycleCallback")]
pub fn set_lifecycle_callback(&self, callback: Function<(String, String), ()>) -> Result<()> {
let tsfn: LifecycleTsfn = Arc::new(
callback
.build_threadsafe_function::<(String, String)>()
.max_queue_size::<8>()
.build_callback(|ctx| Ok(ctx.value))?,
);
let mut guard = self
.lifecycle_tsfn
.lock()
.map_err(|_| generic_error("DirectAudioCapture lifecycle poisoned"))?;
*guard = Some(tsfn);
Ok(())
}
#[napi]
pub fn start(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.start(parsed)))
}
#[napi]
pub fn set_rule(&self, rule: Object) -> Result<bool> {
let parsed = parse_routing_rule(&rule)?;
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
Ok(guard.as_ref().is_some_and(|b| b.set_rule(parsed)))
}
#[napi]
pub fn read<'env>(&self, env: &'env Env) -> Result<Option<NativeAudioFrame<'env>>> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let Some(backend) = guard.as_ref() else {
return Ok(None);
};
let Some(frame) = backend.read() else {
return Ok(None);
};
let arraybuffer = audio_samples_to_arraybuffer(env, &frame.samples)?;
Ok(Some(NativeAudioFrame {
samples: arraybuffer,
sample_rate: frame.sample_rate,
channels: frame.channels,
timestamp_us: frame.timestamp_us.max(0) as f64,
}))
}
#[napi(js_name = "setScreenAudioSink")]
pub fn set_screen_audio_sink(&self, sink_handle: Unknown<'_>) -> Result<()> {
let sink = retain_screen_audio_sink_handle(sink_handle)?;
if !sink.supports_screen_audio() {
return Err(generic_error(
"DirectAudioCapture.setScreenAudioSink handle does not support screen audio",
));
}
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.set_screen_audio_sink(sink);
}
Ok(())
}
#[napi(js_name = "clearScreenAudioSink")]
pub fn clear_screen_audio_sink(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.clear_screen_audio_sink();
}
Ok(())
}
#[napi]
pub fn stop(&self) -> Result<()> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
if let Some(b) = guard.as_ref() {
b.stop();
}
drop(guard);
self.emit_lifecycle("closed-clean", "direct audio capture stopped");
Ok(())
}
#[napi(js_name = "routingGraph")]
pub fn routing_graph(&self) -> Result<RoutingGraphWire> {
let guard = self
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let graph = guard
.as_ref()
.map(|b| b.routing_graph())
.unwrap_or_default();
Ok(RoutingGraphWire(graph))
}
}
impl Default for DirectAudioCapture {
fn default() -> Self {
Self::new()
}
}
impl DirectAudioCapture {
fn emit_lifecycle(&self, kind: &str, message: &str) {
let tsfn = self
.lifecycle_tsfn
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned());
let Some(tsfn) = tsfn else {
return;
};
let _: Status = tsfn.call(
(kind.to_string(), message.to_string()),
ThreadsafeFunctionCallMode::NonBlocking,
);
}
}
#[napi]
pub struct AudioMixRuntimeHandle {
inner: Mutex<Option<crate::audio_mix_runtime::AudioMixRuntime>>,
source_count: u32,
mark_pushed_total: Arc<std::sync::atomic::AtomicU64>,
}
#[napi]
impl AudioMixRuntimeHandle {
#[napi(constructor)]
pub fn new(source_count: u32) -> Result<Self> {
Self::build(source_count, None)
}
#[napi(factory, js_name = "boundToDirectCapture")]
pub fn bound_to_direct_capture(direct: &DirectAudioCapture) -> Result<Self> {
let arc = direct_capture_freshness(direct)?;
Self::build(1, Some(arc))
}
fn build(
source_count: u32,
bound_freshness: Option<Arc<std::sync::atomic::AtomicU64>>,
) -> Result<Self> {
if source_count == 0 {
return Err(invalid_arg(
"AudioMixRuntimeHandle requires at least 1 source",
));
}
if source_count as usize > fluxer_audio_mix::MAX_MIX_SOURCES {
return Err(invalid_arg("AudioMixRuntimeHandle exceeds MAX_MIX_SOURCES"));
}
let clock: Arc<dyn fluxer_rt_thread::MonotonicClock> =
Arc::new(fluxer_rt_thread::SystemMonotonicClock::new());
let mut builder =
crate::audio_mix_runtime::AudioMixRuntimeBuilder::new().with_clock(Arc::clone(&clock));
for index in 0..source_count {
let source_id = (index as u64) + 1;
let (_source, consumer) = crate::audio_mix_runtime::CaptureSource::create(
source_id,
crate::audio_mix_runtime::MIX_SAMPLE_RATE_HZ,
crate::audio_mix_runtime::MIX_CHANNELS,
)
.map_err(|_| generic_error("CaptureSource::create failed"))?;
let freshness = if index == 0 {
match &bound_freshness {
Some(arc) => Arc::clone(arc),
None => Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX)),
}
} else {
Arc::new(std::sync::atomic::AtomicU64::new(u64::MAX))
};
builder = builder.add_source_with_freshness(source_id, consumer, freshness);
}
let runtime = builder
.build(crate::audio_mix_runtime::NullMixOutputSink)
.map_err(|_| generic_error("AudioMixRuntimeBuilder::build failed"))?;
let mark_pushed_total = runtime.mark_pushed_total_arc();
Ok(Self {
inner: Mutex::new(Some(runtime)),
source_count,
mark_pushed_total,
})
}
#[napi(js_name = "sourceCount")]
pub fn source_count_js(&self) -> u32 {
assert!(self.source_count > 0);
assert!(self.source_count as usize <= fluxer_audio_mix::MAX_MIX_SOURCES);
self.source_count
}
#[napi]
pub fn tick(&self, tick_at_ns: Option<i64>) -> Result<u32> {
use fluxer_rt_thread::MonotonicClock as _;
assert!(self.source_count > 0);
let mut guard = self
.inner
.lock()
.map_err(|_| generic_error("AudioMixRuntimeHandle poisoned"))?;
let runtime = guard
.as_mut()
.ok_or_else(|| generic_error("AudioMixRuntimeHandle disposed"))?;
let at_ns: u64 = match tick_at_ns {
Some(v) if v > 0 => v as u64,
_ => fluxer_rt_thread::SystemMonotonicClock::new().now_ns(),
};
assert!(at_ns > 0);
let marked = runtime
.observe_source_pushes_without_mix(at_ns)
.map_err(|_| generic_error("AudioMixRuntime tick failed"))?;
Ok(marked.min(u32::MAX as u64) as u32)
}
#[napi(js_name = "markPushedTotal")]
pub fn mark_pushed_total_js(&self) -> u32 {
assert!(self.source_count > 0);
let value = self
.mark_pushed_total
.load(std::sync::atomic::Ordering::Acquire);
let clamped = value.min(u32::MAX as u64);
assert!(clamped <= u32::MAX as u64);
clamped as u32
}
#[napi]
pub fn dispose(&self) -> Result<()> {
assert!(self.source_count > 0);
assert!(self.source_count as usize <= fluxer_audio_mix::MAX_MIX_SOURCES);
let mut guard = self
.inner
.lock()
.map_err(|_| generic_error("AudioMixRuntimeHandle poisoned"))?;
guard.take();
Ok(())
}
}
fn direct_capture_freshness(
direct: &DirectAudioCapture,
) -> Result<Arc<std::sync::atomic::AtomicU64>> {
let guard = direct
.backend
.lock()
.map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?;
let backend = guard
.as_ref()
.ok_or_else(|| generic_error("DirectAudioCapture backend unavailable"))?;
backend
.last_push_ns_arc()
.ok_or_else(|| generic_error("DirectAudioCapture backend lacks freshness atomic"))
}
#[napi(object)]
pub struct NativeAudioFrame<'env> {
pub samples: ArrayBuffer<'env>,
#[napi(js_name = "sampleRate")]
pub sample_rate: u32,
pub channels: u32,
#[napi(js_name = "timestampUs")]
pub timestamp_us: f64,
}
pub struct PropMapWire(pub PropMap);
impl napi::bindgen_prelude::ToNapiValue for PropMapWire {
unsafe fn to_napi_value(
raw_env: napi::sys::napi_env,
value: Self,
) -> Result<napi::sys::napi_value> {
let env = napi::Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
for (key, val) in value.0 {
object.set(&key, val)?;
}
unsafe {
<Object<'_> as napi::bindgen_prelude::ToNapiValue>::to_napi_value(raw_env, object)
}
}
}
pub struct RoutingGraphWire(pub RoutingGraphSnapshot);
impl napi::bindgen_prelude::ToNapiValue for RoutingGraphWire {
unsafe fn to_napi_value(
raw_env: napi::sys::napi_env,
value: Self,
) -> Result<napi::sys::napi_value> {
let env = napi::Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
object.set("backend", value.0.backend)?;
object.set("nodes", routing_graph_nodes_to_array(&env, value.0.nodes)?)?;
object.set("ports", routing_graph_ports_to_array(&env, value.0.ports)?)?;
object.set(
"ownedLinks",
routing_graph_links_to_array(&env, value.0.owned_links)?,
)?;
unsafe {
<Object<'_> as napi::bindgen_prelude::ToNapiValue>::to_napi_value(raw_env, object)
}
}
}
fn prop_map_to_object<'env>(env: &'env Env, props: PropMap) -> Result<Object<'env>> {
let mut object = Object::new(env)?;
for (key, value) in props {
object.set(&key, value)?;
}
Ok(object)
}
fn routing_graph_nodes_to_array<'env>(
env: &'env Env,
nodes: Vec<crate::backend::RoutingGraphNode>,
) -> Result<Array<'env>> {
let mut array = env.create_array(nodes.len() as u32)?;
for (index, node) in nodes.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("id", node.id)?;
object.set("props", prop_map_to_object(env, node.props)?)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn routing_graph_ports_to_array<'env>(
env: &'env Env,
ports: Vec<crate::backend::RoutingGraphPort>,
) -> Result<Array<'env>> {
let mut array = env.create_array(ports.len() as u32)?;
for (index, port) in ports.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("id", port.id)?;
object.set("nodeId", port.node_id)?;
object.set("direction", port.direction)?;
object.set("channel", port.channel)?;
object.set("props", prop_map_to_object(env, port.props)?)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn routing_graph_links_to_array<'env>(
env: &'env Env,
links: Vec<crate::backend::RoutingGraphLink>,
) -> Result<Array<'env>> {
let mut array = env.create_array(links.len() as u32)?;
for (index, link) in links.into_iter().enumerate() {
let mut object = Object::new(env)?;
object.set("outputNodeId", link.output_node_id)?;
object.set("outputPortId", link.output_port_id)?;
object.set("inputNodeId", link.input_node_id)?;
object.set("inputPortId", link.input_port_id)?;
object.set("owned", true)?;
object.set("passive", true)?;
array.set(index as u32, object)?;
}
Ok(array)
}
fn project_inventory_entry(mut entry: PropMap, fields: &[String]) -> PropMapWire {
if fields.is_empty() {
return PropMapWire(entry);
}
let mut filtered = PropMap::with_capacity(fields.len());
for field in fields {
if let Some(value) = entry.remove(field) {
filtered.insert(field.clone(), value);
}
}
PropMapWire(filtered)
}
fn audio_samples_to_arraybuffer<'env>(
env: &'env Env,
samples: &[f32],
) -> Result<ArrayBuffer<'env>> {
let bytes: Vec<u8> = samples
.iter()
.flat_map(|sample| sample.to_le_bytes())
.collect();
ArrayBuffer::from_data(env, bytes)
}
fn validate_inventory_fields(values: Vec<String>) -> Result<Vec<String>> {
if values.len() as u32 > MAX_INVENTORY_FIELDS {
return Err(invalid_arg("too many inventory fields"));
}
for value in &values {
if value.len() > MAX_INVENTORY_FIELD_LENGTH {
return Err(invalid_arg("inventory field exceeds length cap"));
}
}
Ok(values)
}
fn parse_routing_rule(value: &Object) -> Result<RoutingRule> {
Ok(RoutingRule {
include_when: parse_pattern_list(value, "include")?,
never_when: parse_pattern_list(value, "exclude")?,
pin_target_for: parse_pattern_list(value, "workaround")?,
skip_hardware_devices: read_optional_bool(value, "ignoreDevices")?
.or(read_optional_bool(value, "ignore_devices")?)
.unwrap_or(false),
only_audio_sinks: read_optional_bool(value, "onlySpeakers")?
.or(read_optional_bool(value, "only_speakers")?)
.unwrap_or(false),
only_default_audio_sink: read_optional_bool(value, "onlyDefaultSpeakers")?
.or(read_optional_bool(value, "only_default_speakers")?)
.unwrap_or(false),
})
}
fn parse_pattern_list(value: &Object, name: &str) -> Result<Vec<PropPattern>> {
let Some(raw) = read_optional_unknown(value, name)? else {
return Ok(Vec::new());
};
if matches!(
raw.get_type()?,
napi::ValueType::Null | napi::ValueType::Undefined
) {
return Ok(Vec::new());
}
let array = unsafe { raw.cast::<napi::bindgen_prelude::Array>() }
.map_err(|_| invalid_arg(format!("{name} must be an array of objects")))?;
let len = array.len();
if len > MAX_ROUTING_RULE_PATTERNS {
return Err(invalid_arg(format!("{name} exceeds pattern cap")));
}
let mut out = Vec::with_capacity(len as usize);
for index in 0..len {
let entry = array
.get::<Object>(index)
.map_err(|_| invalid_arg(format!("{name}[{index}] must be an object")))?
.ok_or_else(|| invalid_arg(format!("{name}[{index}] must be an object")))?;
out.push(object_to_prop_map(&entry)?);
}
Ok(out)
}
fn object_to_prop_map(object: &Object) -> Result<PropMap> {
let keys = Object::keys(object)?;
if keys.len() as u32 > MAX_ROUTING_RULE_KEYS_PER_PATTERN {
return Err(invalid_arg("routing pattern has too many keys"));
}
let mut out = PropMap::with_capacity(keys.len());
for key in keys {
if key.is_empty() || key.len() > MAX_ROUTING_RULE_KEY_LENGTH {
return Err(invalid_arg("routing pattern key is empty or too long"));
}
let raw = read_optional_unknown(object, &key)?
.ok_or_else(|| invalid_arg("routing pattern value missing"))?;
if raw.get_type()? != napi::ValueType::String {
return Err(invalid_arg("routing pattern value must be a string"));
}
let value: String = unsafe { raw.cast() }?;
if value.len() > MAX_ROUTING_RULE_VALUE_LENGTH {
return Err(invalid_arg("routing pattern value too long"));
}
out.insert(key, value);
}
Ok(out)
}
fn read_optional_unknown<'a>(object: &Object<'a>, name: &str) -> Result<Option<Unknown<'a>>> {
object.get::<Unknown>(name)
}
fn read_optional_bool(object: &Object, name: &str) -> Result<Option<bool>> {
let Some(raw) = read_optional_unknown(object, name)? else {
return Ok(None);
};
match raw.get_type()? {
napi::ValueType::Null | napi::ValueType::Undefined => Ok(None),
napi::ValueType::Boolean => Ok(Some(unsafe { raw.cast() }?)),
_ => Err(invalid_arg(format!("{name} must be a boolean"))),
}
}
fn generic_error(reason: impl Into<String>) -> Error {
Error::new(Status::GenericFailure, reason.into())
}
fn invalid_arg(reason: impl Into<String>) -> Error {
Error::new(Status::InvalidArg, reason.into())
}
#[allow(dead_code)]
fn _keep_arc_in_scope(_: Arc<()>) {}
#[cfg(all(test, target_os = "linux"))]
mod js_path_tests {
use super::AudioMixRuntimeHandle;
use crate::pipewire::stream_ops::{
DIRECT_CAPTURE_APM_FRAME_SAMPLES, build_test_user_data, process_audio_chunk,
};
use fluxer_rt_thread::MonotonicClock;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug)]
struct FakeClock {
value_ns: AtomicU64,
}
impl FakeClock {
fn new(initial_ns: u64) -> Self {
assert!(initial_ns > 0);
Self {
value_ns: AtomicU64::new(initial_ns),
}
}
}
impl MonotonicClock for FakeClock {
fn now_ns(&self) -> u64 {
self.value_ns.load(Ordering::Acquire)
}
}
fn make_f32_payload(samples: &[f32]) -> Vec<u8> {
assert!(!samples.is_empty());
let mut out = Vec::with_capacity(samples.len() * 4);
for sample in samples {
out.extend_from_slice(&sample.to_ne_bytes());
}
assert_eq!(out.len(), samples.len() * 4);
out
}
fn build_handle_with_shared_freshness(last_push_ns: Arc<AtomicU64>) -> AudioMixRuntimeHandle {
assert!(Arc::strong_count(&last_push_ns) >= 1);
let handle = AudioMixRuntimeHandle::build(1, Some(last_push_ns))
.expect("AudioMixRuntimeHandle build via JS path");
assert_eq!(handle.source_count_js(), 1);
handle
}
#[test]
fn js_runtime_tick_consumes_freshness_pushed_by_production_callback() {
let clock: Arc<dyn MonotonicClock> = Arc::new(FakeClock::new(11_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut user_data = build_test_user_data(Arc::clone(&last_push_ns), Arc::clone(&clock));
let handle = build_handle_with_shared_freshness(Arc::clone(&last_push_ns));
assert_eq!(handle.mark_pushed_total_js(), 0);
assert_eq!(last_push_ns.load(Ordering::Acquire), u64::MAX);
let frame: Vec<f32> = (0..DIRECT_CAPTURE_APM_FRAME_SAMPLES)
.map(|n| (n as f32) * 0.0001)
.collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut user_data, &payload);
let pushed_after_callback = last_push_ns.load(Ordering::Acquire);
assert_ne!(pushed_after_callback, u64::MAX);
assert_eq!(pushed_after_callback, 11_000_000);
let marked = handle
.tick(Some(pushed_after_callback as i64))
.expect("AudioMixRuntimeHandle::tick observes freshness");
assert_eq!(marked, 1);
let total = handle.mark_pushed_total_js();
assert!(
total >= 1,
"AudioMixRuntimeHandle::tick did not advance mark_pushed_total ({total})",
);
handle.dispose().expect("dispose");
}
#[test]
fn js_runtime_tick_idempotent_for_unchanged_freshness_atomic() {
let clock: Arc<dyn MonotonicClock> = Arc::new(FakeClock::new(22_000_000));
let last_push_ns = Arc::new(AtomicU64::new(u64::MAX));
let mut user_data = build_test_user_data(Arc::clone(&last_push_ns), Arc::clone(&clock));
let handle = build_handle_with_shared_freshness(Arc::clone(&last_push_ns));
let frame: Vec<f32> = (0..DIRECT_CAPTURE_APM_FRAME_SAMPLES)
.map(|n| (n as f32) * 0.0002)
.collect();
let payload = make_f32_payload(&frame);
process_audio_chunk(&mut user_data, &payload);
let observed = last_push_ns.load(Ordering::Acquire);
let _ = handle.tick(Some(observed as i64)).expect("first tick");
let after_first = handle.mark_pushed_total_js();
assert!(after_first >= 1);
let _ = handle.tick(Some(observed as i64 + 1)).expect("second tick");
let after_second = handle.mark_pushed_total_js();
assert_eq!(
after_first, after_second,
"second tick must not advance mark_pushed_total when freshness atomic is unchanged",
);
handle.dispose().expect("dispose");
}
}