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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,462 @@
#![allow(non_snake_case, non_camel_case_types)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use core::ffi::c_void;
use core::ptr;
use std::sync::{
Mutex, MutexGuard,
atomic::{AtomicPtr, AtomicU64, Ordering},
};
use objc2::rc::Retained;
use objc2::runtime::{NSObject, NSObjectProtocol, ProtocolObject};
use objc2::{AllocAnyThread, DefinedClass, define_class, msg_send};
use objc2_core_foundation::CFAllocator;
use objc2_core_media::CMSampleBuffer;
use objc2_foundation::NSError;
use objc2_screen_capture_kit::{SCStream, SCStreamDelegate, SCStreamOutput, SCStreamOutputType};
use crate::audio_converter::{self as ac, AudioBufferList, AudioStreamBasicDescription};
use crate::pcm_pool::{PcmFramePool, PooledPcmFrame};
use crate::source_state::Machine;
const MAX_CALLBACK_INPUT_FRAMES: u32 = 48_000;
const MIN_INPUT_SAMPLE_RATE: f64 = 8_000.0;
const MAX_ABL_BUFFERS: usize = 32;
pub type PcmCallback =
unsafe extern "C" fn(ctx: *mut c_void, slot: *mut Option<PooledPcmFrame>, frames: u32);
pub type StopCallback = unsafe extern "C" fn(ctx: *mut c_void, err: *mut NSError);
pub struct SourceOptions {
pub target_sample_rate: f64,
pub target_channels: u32,
}
impl Default for SourceOptions {
fn default() -> Self {
Self {
target_sample_rate: 48_000.0,
target_channels: 2,
}
}
}
const EMPTY_AUDIO_BUFFER: ac::AudioBuffer = ac::AudioBuffer {
m_number_channels: 0,
m_data_byte_size: 0,
m_data: ptr::null_mut(),
};
#[repr(C, align(16))]
struct AblScratch {
n_buffers: u32,
_pad: u32,
buffers: [ac::AudioBuffer; MAX_ABL_BUFFERS],
}
impl AblScratch {
const fn empty() -> Self {
Self {
n_buffers: 0,
_pad: 0,
buffers: [EMPTY_AUDIO_BUFFER; MAX_ABL_BUFFERS],
}
}
}
const _: () = {
assert!(core::mem::align_of::<AblScratch>() == 16);
assert!(
core::mem::size_of::<AblScratch>()
>= core::mem::size_of::<u32>()
+ MAX_ABL_BUFFERS * core::mem::size_of::<ac::AudioBuffer>()
);
assert!(core::mem::offset_of!(AblScratch, buffers) == 8);
assert!(core::mem::size_of::<AblScratch>() >= core::mem::size_of::<AudioBufferList>());
};
pub struct Source {
pub delegate: Retained<FluxerSCKAudioSource>,
pub state: Machine,
pub target_sample_rate: f64,
pub target_channels: u32,
scratch: Mutex<SourceScratch>,
callbacks: Mutex<Callbacks>,
pub dropped_buffers: AtomicU64,
pub output_queue: dispatch2::DispatchRetained<dispatch2::DispatchQueue>,
}
struct SourceScratch {
abl: AblScratch,
}
struct Callbacks {
pcm_callback: Option<PcmCallback>,
pcm_callback_ctx: *mut c_void,
stop_callback: Option<StopCallback>,
stop_callback_ctx: *mut c_void,
pcm_pool: Option<PcmFramePool>,
}
unsafe impl Send for Source {}
unsafe impl Sync for Source {}
#[derive(Debug)]
pub enum SourceCreateError {
DispatchQueue,
}
#[derive(Debug)]
pub struct ClassRegistrationError;
pub struct DelegateIvars {
pub source: AtomicPtr<Source>,
}
define_class!(
#[unsafe(super(NSObject))]
#[name = "FluxerSCKAudioSource"]
#[ivars = DelegateIvars]
pub struct FluxerSCKAudioSource;
unsafe impl NSObjectProtocol for FluxerSCKAudioSource {}
unsafe impl SCStreamDelegate for FluxerSCKAudioSource {
#[unsafe(method(stream:didStopWithError:))]
unsafe fn did_stop_with_error(&self, _stream: &SCStream, err: &NSError) {
let ptr = self.ivars().source.load(Ordering::Acquire);
if ptr.is_null() {
return;
}
let src = unsafe { &*ptr };
let _ = src.state.request_stop();
let _ = src.state.mark_stopped();
let (cb, ctx) = {
let callbacks = src.lock_callbacks();
(callbacks.stop_callback, callbacks.stop_callback_ctx)
};
if let Some(cb) = cb {
unsafe {
cb(ctx, err as *const NSError as *mut NSError);
}
}
}
}
unsafe impl SCStreamOutput for FluxerSCKAudioSource {
#[unsafe(method(stream:didOutputSampleBuffer:ofType:))]
unsafe fn did_output_sample_buffer(
&self,
_stream: &SCStream,
sample_buffer: &CMSampleBuffer,
output_type: SCStreamOutputType,
) {
if output_type != SCStreamOutputType::Audio {
return;
}
let ptr = self.ivars().source.load(Ordering::Acquire);
if ptr.is_null() {
return;
}
unsafe {
handle_audio_sample(&*ptr, sample_buffer);
}
}
}
);
impl FluxerSCKAudioSource {
pub fn new() -> Retained<Self> {
let this = Self::alloc().set_ivars(DelegateIvars {
source: AtomicPtr::new(core::ptr::null_mut()),
});
unsafe { msg_send![super(this), init] }
}
}
struct RetainedBlockGuard(*mut objc2_core_media::CMBlockBuffer);
impl Drop for RetainedBlockGuard {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
let _ = objc2_core_foundation::CFRetained::from_raw(
core::ptr::NonNull::new_unchecked(self.0),
);
}
self.0 = ptr::null_mut();
}
}
}
enum AblFillOutcome {
Filled(RetainedBlockGuard),
TooLarge,
Unavailable,
}
fn read_stream_asbd(sample_buffer: &CMSampleBuffer) -> Option<AudioStreamBasicDescription> {
let desc = unsafe { sample_buffer.format_description() }?;
unsafe extern "C-unwind" {
fn CMAudioFormatDescriptionGetStreamBasicDescription(
desc: &objc2_core_media::CMFormatDescription,
) -> *const AudioStreamBasicDescription;
}
let asbd_ptr = unsafe { CMAudioFormatDescriptionGetStreamBasicDescription(&desc) };
if asbd_ptr.is_null() {
return None;
}
Some(unsafe { *asbd_ptr })
}
unsafe fn admit_sample(
src: &Source,
sample_buffer: &CMSampleBuffer,
) -> Option<(u32, AudioStreamBasicDescription)> {
if !unsafe { sample_buffer.data_is_ready() } {
return None;
}
let num_samples = unsafe { sample_buffer.num_samples() };
if num_samples <= 0 || (num_samples as u32) > MAX_CALLBACK_INPUT_FRAMES {
return None;
}
let asbd = read_stream_asbd(sample_buffer)?;
if !asbd.m_sample_rate.is_finite() {
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return None;
}
if asbd.m_sample_rate < MIN_INPUT_SAMPLE_RATE {
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return None;
}
Some((num_samples as u32, asbd))
}
unsafe fn fill_abl_scratch(sample_buffer: &CMSampleBuffer, abl: &mut AblScratch) -> AblFillOutcome {
assert!(core::mem::size_of::<AblScratch>() >= core::mem::size_of::<AudioBufferList>());
let mut abl_size: usize = 0;
let size_status = unsafe {
sample_buffer.audio_buffer_list_with_retained_block_buffer(
&mut abl_size,
ptr::null_mut(),
0,
None::<&CFAllocator>,
None::<&CFAllocator>,
0,
ptr::null_mut(),
)
};
if size_status != 0 || abl_size < core::mem::size_of::<AudioBufferList>() {
return AblFillOutcome::Unavailable;
}
if abl_size > core::mem::size_of::<AblScratch>() {
return AblFillOutcome::TooLarge;
}
let abl_ptr = (abl as *mut AblScratch) as *mut objc2_core_audio_types::AudioBufferList;
let mut retained_block: *mut objc2_core_media::CMBlockBuffer = ptr::null_mut();
let list_status = unsafe {
sample_buffer.audio_buffer_list_with_retained_block_buffer(
ptr::null_mut(),
abl_ptr,
core::mem::size_of::<AblScratch>(),
None::<&CFAllocator>,
None::<&CFAllocator>,
0,
&mut retained_block,
)
};
let guard = RetainedBlockGuard(retained_block);
if list_status != 0 {
return AblFillOutcome::Unavailable;
}
if abl.n_buffers as usize > MAX_ABL_BUFFERS {
return AblFillOutcome::Unavailable;
}
AblFillOutcome::Filled(guard)
}
unsafe fn handle_audio_sample(src: &Source, sample_buffer: &CMSampleBuffer) {
let Some((frames, asbd)) = (unsafe { admit_sample(src, sample_buffer) }) else {
return;
};
let (cb, ctx, pool) = {
let callbacks = src.lock_callbacks();
(
callbacks.pcm_callback,
callbacks.pcm_callback_ctx,
callbacks.pcm_pool.clone(),
)
};
let Some(cb) = cb else { return };
let Some(pool) = pool else { return };
let out_capacity =
ac::output_frame_capacity(frames, asbd.m_sample_rate, src.target_sample_rate);
let needed = (out_capacity as usize) * (src.target_channels as usize);
if needed > pool.samples_per_slot() as usize {
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return;
}
let mut scratch = match src.scratch.try_lock() {
Ok(guard) => guard,
Err(_) => {
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return;
}
};
let block_guard = match unsafe { fill_abl_scratch(sample_buffer, &mut scratch.abl) } {
AblFillOutcome::Filled(guard) => guard,
AblFillOutcome::TooLarge => {
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return;
}
AblFillOutcome::Unavailable => return,
};
let Some(mut slot) = pool.try_acquire() else {
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return;
};
let out_frames = unsafe {
ac::convert_buffer_list_to_interleaved_f32(
&asbd,
(&raw const scratch.abl) as *const AudioBufferList,
frames,
src.target_sample_rate,
src.target_channels,
slot.unfilled_mut(),
)
};
drop(block_guard);
drop(scratch);
let frames_emitted = match out_frames {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let filled = (frames_emitted as usize) * (src.target_channels as usize);
assert!(filled <= slot.capacity());
slot.set_filled_len(filled);
let mut handoff = Some(slot);
unsafe {
cb(
ctx,
&mut handoff as *mut Option<PooledPcmFrame>,
frames_emitted,
);
}
}
impl Source {
pub fn create(opts: SourceOptions) -> Result<Box<Source>, SourceCreateError> {
assert!(opts.target_sample_rate > 0.0);
assert!(opts.target_channels > 0);
let delegate = FluxerSCKAudioSource::new();
let queue_attr = dispatch2::DispatchQueueAttr::with_qos_class(
dispatch2::DispatchQueueAttr::SERIAL,
dispatch2::DispatchQoS::UserInteractive,
0,
);
let queue =
dispatch2::DispatchQueue::new("app.fluxer.mac-app-audio.sck", Some(&queue_attr));
let mut src = Box::new(Source {
delegate,
state: Machine::new(),
target_sample_rate: opts.target_sample_rate,
target_channels: opts.target_channels,
scratch: Mutex::new(SourceScratch {
abl: AblScratch::empty(),
}),
callbacks: Mutex::new(Callbacks {
pcm_callback: None,
pcm_callback_ctx: ptr::null_mut(),
stop_callback: None,
stop_callback_ctx: ptr::null_mut(),
pcm_pool: None,
}),
dropped_buffers: AtomicU64::new(0),
output_queue: queue,
});
let raw: *mut Source = &mut *src;
src.delegate.ivars().source.store(raw, Ordering::Release);
Ok(src)
}
fn lock_callbacks(&self) -> MutexGuard<'_, Callbacks> {
self.callbacks
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn set_pcm_callback(&mut self, cb: PcmCallback, ctx: *mut c_void) {
let mut callbacks = self.lock_callbacks();
callbacks.pcm_callback = Some(cb);
callbacks.pcm_callback_ctx = ctx;
}
pub fn set_pcm_pool(&mut self, pool: PcmFramePool) {
assert!(pool.capacity() > 0);
assert!(pool.samples_per_slot() > 0);
let mut callbacks = self.lock_callbacks();
callbacks.pcm_pool = Some(pool);
}
pub fn set_stop_callback(&mut self, cb: StopCallback, ctx: *mut c_void) {
let mut callbacks = self.lock_callbacks();
callbacks.stop_callback = Some(cb);
callbacks.stop_callback_ctx = ctx;
}
pub fn clear_stop_callback(&mut self) {
let mut callbacks = self.lock_callbacks();
callbacks.stop_callback = None;
callbacks.stop_callback_ctx = ptr::null_mut();
}
pub fn delegate_as_output(&self) -> &ProtocolObject<dyn SCStreamOutput> {
ProtocolObject::from_ref(&*self.delegate)
}
pub fn delegate_as_delegate(&self) -> &ProtocolObject<dyn SCStreamDelegate> {
ProtocolObject::from_ref(&*self.delegate)
}
pub fn output_queue(&self) -> &dispatch2::DispatchQueue {
&self.output_queue
}
}
impl Drop for Source {
fn drop(&mut self) {
self.delegate
.ivars()
.source
.store(ptr::null_mut(), Ordering::Release);
}
}
#[cfg(test)]
mod abl_scratch_tests {
use super::*;
#[test]
fn abl_scratch_layout_matches_audio_buffer_list() {
assert_eq!(16, core::mem::align_of::<AblScratch>());
assert_eq!(8, core::mem::offset_of!(AblScratch, buffers));
assert!(
core::mem::size_of::<AblScratch>()
>= core::mem::size_of::<u32>()
+ MAX_ABL_BUFFERS * core::mem::size_of::<ac::AudioBuffer>()
);
}
#[test]
fn abl_scratch_empty_has_no_buffers() {
let scratch = AblScratch::empty();
assert_eq!(0, scratch.n_buffers);
assert!(scratch.buffers[0].m_data.is_null());
assert_eq!(0, scratch.buffers[MAX_ABL_BUFFERS - 1].m_data_byte_size);
}
}
@@ -0,0 +1,953 @@
#![allow(non_camel_case_types, non_upper_case_globals)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use core::ffi::{CStr, c_void};
use core::ptr;
use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use objc2::AllocAnyThread;
use objc2::rc::Retained;
use objc2_core_audio::{
AudioDeviceCreateIOProcID, AudioDeviceDestroyIOProcID, AudioDeviceIOProcID, AudioDeviceStart,
AudioDeviceStop, AudioHardwareCreateAggregateDevice, AudioHardwareCreateProcessTap,
AudioHardwareDestroyAggregateDevice, AudioHardwareDestroyProcessTap,
AudioObjectGetPropertyData, AudioObjectID, AudioObjectPropertyAddress,
AudioObjectSetPropertyData, CATapDescription, CATapMuteBehavior,
kAudioHardwarePropertyTranslatePIDToProcessObject, kAudioObjectPropertyElementMain,
kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, kAudioObjectUnknown,
kAudioTapPropertyDescription, kAudioTapPropertyFormat, kAudioTapPropertyUID,
};
use objc2_core_foundation::CFDictionary;
use objc2_foundation::{
NSArray, NSBundle, NSMutableArray, NSMutableDictionary, NSNumber, NSObject, NSString, NSUUID,
};
use crate::audio_converter::{self as ac, AudioBufferList, AudioStreamBasicDescription};
use crate::foundation;
use crate::pcm_pool::{PcmFramePool, PooledPcmFrame};
use crate::process_tree;
pub type OSStatus = i32;
const NO_ERR: OSStatus = 0;
const K_AGGREGATE_DRIFT_COMPENSATION_MEDIUM_QUALITY: u32 = 0x40;
const MAX_RELATED_PROCESSES: usize = 512;
const MAX_CALLBACK_INPUT_FRAMES: u32 = 48_000;
const TARGET_SAMPLE_RATE: f64 = 48_000.0;
const TARGET_CHANNELS: u32 = 2;
const LATE_SPAWN_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
const HELPER_BUNDLE_SUFFIXES: &[&str] = &[
".helper",
".helper.Renderer",
".helper.GPU",
".helper.Plugin",
];
static AGGREGATE_UID_COUNTER: AtomicU64 = AtomicU64::new(1);
static DEBUG_COREAUDIO: AtomicBool = AtomicBool::new(false);
static DEBUG_CALLBACK_COUNT: AtomicU32 = AtomicU32::new(0);
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum CaptureScope {
Process,
System,
}
#[derive(Debug)]
pub enum CreateError {
Unsupported,
NoRelatedProcesses,
NoProcessObjects,
FoundationObjectFailed,
CreateProcessTapFailed,
ReadTapUidFailed,
ReadTapFormatFailed,
CreateAggregateDeviceFailed,
CreateIOProcFailed,
StartDeviceFailed,
}
pub type PcmCallback =
unsafe extern "C" fn(ctx: *mut c_void, slot: *mut Option<PooledPcmFrame>, frames: u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TapDiagnostics {
pub convert_failures: u64,
pub dropped_buffers: u64,
}
pub struct Capture {
pub tap_id: AudioObjectID,
pub aggregate_device_id: AudioObjectID,
pub io_proc_id: AudioDeviceIOProcID,
pub input_format: AudioStreamBasicDescription,
pub running: AtomicBool,
pub pcm_callback: Option<PcmCallback>,
pub pcm_callback_ctx: *mut c_void,
pub pcm_pool: Option<PcmFramePool>,
pub convert_failures: AtomicU64,
pub dropped_buffers: AtomicU64,
refresher: Option<RefresherHandle>,
}
unsafe impl Send for Capture {}
unsafe impl Sync for Capture {}
struct RefresherHandle {
alive: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl RefresherHandle {
fn shutdown(&mut self) {
self.alive.store(false, Ordering::Release);
if let Some(handle) = self.thread.take() {
let _ = handle.join();
}
}
}
impl Drop for RefresherHandle {
fn drop(&mut self) {
self.shutdown();
}
}
pub fn is_supported() -> bool {
use objc2::runtime::AnyClass;
AnyClass::get(c"CATapDescription").is_some()
}
fn getenv_set(name: &CStr) -> bool {
unsafe { !libc::getenv(name.as_ptr()).is_null() }
}
fn collect_target_pids(pid: i32, include_process_tree: bool) -> Vec<i32> {
if !include_process_tree {
return vec![pid];
}
process_tree::collect_related_pids(pid, MAX_RELATED_PROCESSES)
}
fn collect_process_objects(pids: &[i32], skip_current_process: bool) -> Vec<AudioObjectID> {
let self_pid = unsafe { libc::getpid() };
let mut out = Vec::with_capacity(pids.len());
for &pid in pids {
if pid <= 0 {
continue;
}
if skip_current_process && pid == self_pid {
continue;
}
let object = translate_pid_to_process_object(pid);
if object == kAudioObjectUnknown {
continue;
}
if out.contains(&object) {
continue;
}
out.push(object);
}
out
}
fn translate_pid_to_process_object(pid: i32) -> AudioObjectID {
let mut out: AudioObjectID = kAudioObjectUnknown;
let mut size: u32 = core::mem::size_of::<AudioObjectID>() as u32;
let mut qualifier_pid = pid;
let address = AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let status = unsafe {
AudioObjectGetPropertyData(
kAudioObjectSystemObject as AudioObjectID,
ptr::NonNull::from(&address),
core::mem::size_of::<i32>() as u32,
(&raw mut qualifier_pid) as *const _ as *const c_void,
ptr::NonNull::from(&mut size),
ptr::NonNull::from(&mut out).cast::<c_void>(),
)
};
if status != NO_ERR {
return kAudioObjectUnknown;
}
out
}
fn build_process_array(objects: &[AudioObjectID]) -> Retained<NSArray<NSNumber>> {
let nums: Vec<Retained<NSNumber>> = objects.iter().map(|o| NSNumber::new_u32(*o)).collect();
let refs: Vec<&NSNumber> = nums.iter().map(|n| n.as_ref()).collect();
NSArray::from_slice(&refs)
}
fn create_tap_description(
process_objects: &[AudioObjectID],
pid: i32,
scope: CaptureScope,
) -> Result<Retained<CATapDescription>, CreateError> {
let process_array = build_process_array(process_objects);
let alloc = CATapDescription::alloc();
let description = match scope {
CaptureScope::Process => unsafe {
CATapDescription::initStereoMixdownOfProcesses(alloc, &process_array)
},
CaptureScope::System => unsafe {
CATapDescription::initStereoGlobalTapButExcludeProcesses(alloc, &process_array)
},
};
let excludes_by_bundle_id = if scope == CaptureScope::System {
apply_main_bundle_id_excludes(&description)
} else {
false
};
if scope == CaptureScope::Process && process_objects.is_empty() {
return Err(CreateError::NoProcessObjects);
}
if scope == CaptureScope::System && process_objects.is_empty() && !excludes_by_bundle_id {
return Err(CreateError::NoProcessObjects);
}
let name = match scope {
CaptureScope::Process => format!("Fluxer app audio tap pid {pid}"),
CaptureScope::System => format!("Fluxer desktop audio tap excluding pid {pid}"),
};
unsafe {
description.setUUID(&NSUUID::UUID());
description.setName(&NSString::from_str(&name));
description.setPrivate(true);
description.setExclusive(scope == CaptureScope::System);
description.setMuteBehavior(CATapMuteBehavior(0));
}
Ok(description)
}
fn apply_main_bundle_id_excludes(description: &CATapDescription) -> bool {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
let obj: &NSObject = description.as_ref();
if !obj.respondsToSelector(sel!(setBundleIDs:)) {
return false;
}
let bundle_array = match build_main_bundle_id_array() {
Some(a) => a,
None => return false,
};
if bundle_array.count() == 0 {
return false;
}
unsafe {
description.setBundleIDs(&bundle_array);
if obj.respondsToSelector(sel!(setProcessRestoreEnabled:)) {
description.setProcessRestoreEnabled(true);
}
}
true
}
fn build_main_bundle_id_array() -> Option<Retained<NSArray<NSString>>> {
let base = copy_main_bundle_identifier()?;
let base_str = base.to_string();
if base_str.is_empty() || base_str.len() > 192 {
return None;
}
let mut entries: Vec<Retained<NSString>> = Vec::with_capacity(1 + HELPER_BUNDLE_SUFFIXES.len());
entries.push(base);
for suffix in HELPER_BUNDLE_SUFFIXES {
let helper = format!("{base_str}{suffix}");
entries.push(NSString::from_str(&helper));
}
let refs: Vec<&NSString> = entries.iter().map(|s| s.as_ref()).collect();
Some(NSArray::from_slice(&refs))
}
fn copy_main_bundle_identifier() -> Option<Retained<NSString>> {
let bundle = NSBundle::mainBundle();
bundle.bundleIdentifier()
}
fn copy_tap_uid(tap_id: AudioObjectID) -> Result<Retained<NSString>, CreateError> {
let mut tap_uid: *const NSString = ptr::null();
let mut size: u32 = core::mem::size_of::<*const NSString>() as u32;
let address = AudioObjectPropertyAddress {
mSelector: kAudioTapPropertyUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let status = unsafe {
AudioObjectGetPropertyData(
tap_id,
ptr::NonNull::from(&address),
0,
ptr::null(),
ptr::NonNull::from(&mut size),
ptr::NonNull::from(&mut tap_uid).cast::<c_void>(),
)
};
if status != NO_ERR || tap_uid.is_null() {
return Err(CreateError::ReadTapUidFailed);
}
unsafe { Retained::from_raw(tap_uid as *mut NSString).ok_or(CreateError::ReadTapUidFailed) }
}
fn read_tap_format(tap_id: AudioObjectID) -> Result<AudioStreamBasicDescription, CreateError> {
let mut format: AudioStreamBasicDescription = unsafe { core::mem::zeroed() };
let mut size: u32 = core::mem::size_of::<AudioStreamBasicDescription>() as u32;
let address = AudioObjectPropertyAddress {
mSelector: kAudioTapPropertyFormat,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let status = unsafe {
AudioObjectGetPropertyData(
tap_id,
ptr::NonNull::from(&address),
0,
ptr::null(),
ptr::NonNull::from(&mut size),
ptr::NonNull::from(&mut format).cast::<c_void>(),
)
};
if status != NO_ERR {
return Err(CreateError::ReadTapFormatFailed);
}
Ok(format)
}
fn create_aggregate_description(
tap_uid: &NSString,
pid: i32,
) -> Result<Retained<NSMutableDictionary<NSString, NSObject>>, CreateError> {
let tap_dict: Retained<NSMutableDictionary<NSString, NSObject>> = NSMutableDictionary::new();
foundation::dict_set_str_key(&tap_dict, c"uid", tap_uid.as_ref());
let drift = NSNumber::new_bool(true);
foundation::dict_set_str_key(&tap_dict, c"drift", drift.as_ref());
let drift_quality = NSNumber::new_u32(K_AGGREGATE_DRIFT_COMPENSATION_MEDIUM_QUALITY);
foundation::dict_set_str_key(&tap_dict, c"drift quality", drift_quality.as_ref());
let dict_obj: &NSObject = tap_dict.as_ref();
let tap_list: Retained<NSMutableArray<NSObject>> = NSMutableArray::arrayWithCapacity(1);
tap_list.addObject(dict_obj);
let aggregate_dict: Retained<NSMutableDictionary<NSString, NSObject>> =
NSMutableDictionary::new();
let counter = AGGREGATE_UID_COUNTER.fetch_add(1, Ordering::Relaxed);
let uid_string = format!("app.fluxer.mac-app-audio.tap.{pid}.{counter}");
let uid = NSString::from_str(&uid_string);
let name = NSString::from_str("Fluxer app audio capture");
foundation::dict_set_str_key(&aggregate_dict, c"uid", uid.as_ref());
foundation::dict_set_str_key(&aggregate_dict, c"name", name.as_ref());
let priv_n = NSNumber::new_bool(true);
foundation::dict_set_str_key(&aggregate_dict, c"private", priv_n.as_ref());
foundation::dict_set_str_key(&aggregate_dict, c"taps", tap_list.as_ref());
let auto = NSNumber::new_bool(true);
foundation::dict_set_str_key(&aggregate_dict, c"tapautostart", auto.as_ref());
Ok(aggregate_dict)
}
unsafe extern "C-unwind" fn io_proc(
_in_device: AudioObjectID,
_in_now: core::ptr::NonNull<objc2_core_audio_types::AudioTimeStamp>,
in_input_data: core::ptr::NonNull<objc2_core_audio_types::AudioBufferList>,
_in_input_time: core::ptr::NonNull<objc2_core_audio_types::AudioTimeStamp>,
_out_output_data: core::ptr::NonNull<objc2_core_audio_types::AudioBufferList>,
_in_output_time: core::ptr::NonNull<objc2_core_audio_types::AudioTimeStamp>,
client_data: *mut c_void,
) -> OSStatus {
if client_data.is_null() {
return NO_ERR;
}
let self_ptr = client_data as *mut Capture;
let capture = unsafe { &*self_ptr };
if !capture.running.load(Ordering::Acquire) {
return NO_ERR;
}
let local_abl = in_input_data.as_ptr() as *const AudioBufferList;
let frames =
match unsafe { ac::input_frame_count_for_buffer_list(&capture.input_format, local_abl) } {
Ok(f) => f,
Err(_) => {
capture.convert_failures.fetch_add(1, Ordering::Relaxed);
return NO_ERR;
}
};
if frames == 0 {
return NO_ERR;
}
if frames > MAX_CALLBACK_INPUT_FRAMES {
capture.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return NO_ERR;
}
unsafe {
convert_and_deliver(capture, local_abl, frames);
}
NO_ERR
}
unsafe fn convert_and_deliver(capture: &Capture, local_abl: *const AudioBufferList, frames: u32) {
assert!(frames > 0);
assert!(frames <= MAX_CALLBACK_INPUT_FRAMES);
let Some(cb) = capture.pcm_callback else {
return;
};
let Some(pool) = capture.pcm_pool.as_ref() else {
return;
};
let needed = (ac::output_frame_capacity(
frames,
capture.input_format.m_sample_rate,
TARGET_SAMPLE_RATE,
) as usize)
* (TARGET_CHANNELS as usize);
if needed > pool.samples_per_slot() as usize {
capture.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return;
}
let Some(mut slot) = pool.try_acquire() else {
capture.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return;
};
let out_frames = unsafe {
ac::convert_buffer_list_to_interleaved_f32(
&capture.input_format,
local_abl,
frames,
TARGET_SAMPLE_RATE,
TARGET_CHANNELS,
slot.unfilled_mut(),
)
};
let n = match out_frames {
Ok(0) => {
capture.dropped_buffers.fetch_add(1, Ordering::Relaxed);
return;
}
Err(_) => {
capture.convert_failures.fetch_add(1, Ordering::Relaxed);
return;
}
Ok(n) => n,
};
let filled = (n as usize) * (TARGET_CHANNELS as usize);
assert!(filled <= slot.capacity());
slot.set_filled_len(filled);
let mut handoff = Some(slot);
unsafe {
cb(
capture.pcm_callback_ctx,
&mut handoff as *mut Option<PooledPcmFrame>,
n,
);
}
}
impl Capture {
pub fn create(
pid: i32,
include_process_tree: bool,
scope: CaptureScope,
) -> Result<Box<Capture>, CreateError> {
if !is_supported() {
return Err(CreateError::Unsupported);
}
DEBUG_COREAUDIO.store(
getenv_set(c"FLUXER_MAC_APP_AUDIO_DEBUG_COREAUDIO"),
Ordering::Release,
);
DEBUG_CALLBACK_COUNT.store(0, Ordering::Release);
let root_pid = if scope == CaptureScope::System {
unsafe { libc::getpid() }
} else {
pid
};
let related_pids = collect_target_pids(root_pid, include_process_tree);
if related_pids.is_empty() {
return Err(CreateError::NoRelatedProcesses);
}
let process_objects =
collect_process_objects(&related_pids, scope == CaptureScope::Process);
let description = create_tap_description(&process_objects, root_pid, scope)?;
let mut tap_id: AudioObjectID = kAudioObjectUnknown;
let tap_status = unsafe { AudioHardwareCreateProcessTap(Some(&description), &mut tap_id) };
if tap_status != NO_ERR || tap_id == kAudioObjectUnknown {
return Err(CreateError::CreateProcessTapFailed);
}
let tap_uid = match copy_tap_uid(tap_id) {
Ok(u) => u,
Err(e) => {
unsafe {
let _ = AudioHardwareDestroyProcessTap(tap_id);
}
return Err(e);
}
};
let input_format = match read_tap_format(tap_id) {
Ok(f) => f,
Err(e) => {
unsafe {
let _ = AudioHardwareDestroyProcessTap(tap_id);
}
return Err(e);
}
};
let aggregate_description = match create_aggregate_description(&tap_uid, pid) {
Ok(d) => d,
Err(e) => {
unsafe {
let _ = AudioHardwareDestroyProcessTap(tap_id);
}
return Err(e);
}
};
let mut aggregate_device_id: AudioObjectID = kAudioObjectUnknown;
let aggregate_status = unsafe {
let dict_ref: &CFDictionary =
&*(&*aggregate_description as *const _ as *const CFDictionary);
AudioHardwareCreateAggregateDevice(
dict_ref,
ptr::NonNull::from(&mut aggregate_device_id),
)
};
if aggregate_status != NO_ERR || aggregate_device_id == kAudioObjectUnknown {
unsafe {
let _ = AudioHardwareDestroyProcessTap(tap_id);
}
return Err(CreateError::CreateAggregateDeviceFailed);
}
let mut capture = Box::new(Capture {
tap_id,
aggregate_device_id,
io_proc_id: None,
input_format,
running: AtomicBool::new(false),
pcm_callback: None,
pcm_callback_ctx: ptr::null_mut(),
pcm_pool: None,
convert_failures: AtomicU64::new(0),
dropped_buffers: AtomicU64::new(0),
refresher: None,
});
let mut io_proc_id: AudioDeviceIOProcID = None;
let io_status = unsafe {
AudioDeviceCreateIOProcID(
aggregate_device_id,
Some(io_proc),
&mut *capture as *mut Capture as *mut c_void,
ptr::NonNull::from(&mut io_proc_id),
)
};
if io_status != NO_ERR || io_proc_id.is_none() {
unsafe {
let _ = AudioHardwareDestroyAggregateDevice(aggregate_device_id);
let _ = AudioHardwareDestroyProcessTap(tap_id);
}
return Err(CreateError::CreateIOProcFailed);
}
capture.io_proc_id = io_proc_id;
if scope == CaptureScope::Process && include_process_tree {
capture.refresher =
spawn_late_spawn_refresher(tap_id, root_pid, scope, process_objects.to_vec());
}
Ok(capture)
}
pub fn set_pcm_callback(&mut self, cb: PcmCallback, ctx: *mut c_void) {
self.pcm_callback = Some(cb);
self.pcm_callback_ctx = ctx;
}
pub fn set_pcm_pool(&mut self, pool: PcmFramePool) {
assert!(pool.capacity() > 0);
assert!(pool.samples_per_slot() > 0);
self.pcm_pool = Some(pool);
}
pub fn diagnostics(&self) -> TapDiagnostics {
let diagnostics = TapDiagnostics {
convert_failures: self.convert_failures.load(Ordering::Relaxed),
dropped_buffers: self.dropped_buffers.load(Ordering::Relaxed),
};
assert!(diagnostics.convert_failures <= u64::MAX / 2);
assert!(diagnostics.dropped_buffers <= u64::MAX / 2);
diagnostics
}
pub fn start(&mut self) -> Result<(), CreateError> {
if self.io_proc_id.is_none() || self.aggregate_device_id == kAudioObjectUnknown {
return Err(CreateError::StartDeviceFailed);
}
self.running.store(true, Ordering::Release);
let status = unsafe { AudioDeviceStart(self.aggregate_device_id, self.io_proc_id) };
if status != NO_ERR {
self.running.store(false, Ordering::Release);
return Err(CreateError::StartDeviceFailed);
}
Ok(())
}
pub fn stop(&mut self) {
self.running.store(false, Ordering::Release);
if self.aggregate_device_id != kAudioObjectUnknown && self.io_proc_id.is_some() {
unsafe {
let _ = AudioDeviceStop(self.aggregate_device_id, self.io_proc_id);
let _ = AudioDeviceDestroyIOProcID(self.aggregate_device_id, self.io_proc_id);
}
self.io_proc_id = None;
}
}
}
impl Drop for Capture {
fn drop(&mut self) {
if let Some(mut r) = self.refresher.take() {
r.shutdown();
}
self.stop();
if self.aggregate_device_id != kAudioObjectUnknown {
unsafe {
let _ = AudioHardwareDestroyAggregateDevice(self.aggregate_device_id);
}
self.aggregate_device_id = kAudioObjectUnknown;
}
if self.tap_id != kAudioObjectUnknown {
unsafe {
let _ = AudioHardwareDestroyProcessTap(self.tap_id);
}
self.tap_id = kAudioObjectUnknown;
}
}
}
fn apply_process_objects_to_tap(
tap_id: AudioObjectID,
objects: &[AudioObjectID],
root_pid: i32,
scope: CaptureScope,
) -> OSStatus {
if tap_id == kAudioObjectUnknown {
return -1;
}
let description = match create_tap_description(objects, root_pid, scope) {
Ok(d) => d,
Err(_) => return -1,
};
let address = AudioObjectPropertyAddress {
mSelector: kAudioTapPropertyDescription,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let desc_ptr: *const CATapDescription = &*description;
let mut desc_holder: *const CATapDescription = desc_ptr;
unsafe {
AudioObjectSetPropertyData(
tap_id,
ptr::NonNull::from(&address),
0,
ptr::null(),
core::mem::size_of::<*const CATapDescription>() as u32,
ptr::NonNull::from(&mut desc_holder).cast::<c_void>(),
)
}
}
fn spawn_late_spawn_refresher(
tap_id: AudioObjectID,
root_pid: i32,
scope: CaptureScope,
initial_objects: Vec<AudioObjectID>,
) -> Option<RefresherHandle> {
let alive = Arc::new(AtomicBool::new(true));
let alive_for_thread = alive.clone();
let thread = thread::Builder::new()
.name("fluxer-mac-tap-refresh".into())
.spawn(move || {
run_late_spawn_refresher(tap_id, root_pid, scope, initial_objects, alive_for_thread);
})
.ok()?;
Some(RefresherHandle {
alive,
thread: Some(thread),
})
}
fn run_late_spawn_refresher(
tap_id: AudioObjectID,
root_pid: i32,
scope: CaptureScope,
initial_objects: Vec<AudioObjectID>,
alive: Arc<AtomicBool>,
) {
let mut previous: Vec<AudioObjectID> = initial_objects;
previous.sort_unstable();
previous.dedup();
while alive.load(Ordering::Acquire) {
thread::sleep(LATE_SPAWN_REFRESH_INTERVAL);
if !alive.load(Ordering::Acquire) {
break;
}
let related = process_tree::collect_related_pids(root_pid, MAX_RELATED_PROCESSES);
if related.is_empty() {
continue;
}
let mut current = collect_process_objects(&related, scope == CaptureScope::Process);
current.sort_unstable();
current.dedup();
if current == previous {
continue;
}
if current.is_empty() {
continue;
}
let status = apply_process_objects_to_tap(tap_id, &current, root_pid, scope);
if status == NO_ERR {
previous = current;
}
}
}
pub fn coreaudio_error_message(e: &CreateError) -> &'static str {
match e {
CreateError::Unsupported => "CoreAudio process taps unavailable",
CreateError::NoRelatedProcesses => "No related process for selected app",
CreateError::NoProcessObjects => "No CoreAudio process object for selected app",
CreateError::FoundationObjectFailed => "CoreAudio tap configuration failed",
CreateError::CreateProcessTapFailed => "CoreAudio process tap creation failed",
CreateError::ReadTapUidFailed => "CoreAudio process tap UID lookup failed",
CreateError::ReadTapFormatFailed => "CoreAudio process tap format lookup failed",
CreateError::CreateAggregateDeviceFailed => {
"CoreAudio process tap aggregate device creation failed"
}
CreateError::CreateIOProcFailed => "CoreAudio process tap IOProc creation failed",
CreateError::StartDeviceFailed => "CoreAudio process tap start failed",
}
}
#[cfg(test)]
mod io_proc_diagnostics_tests {
use super::*;
use crate::audio_converter::AudioBuffer;
use core::mem::size_of;
use core::ptr::NonNull;
use core::sync::atomic::AtomicU32 as TestAtomicU32;
use objc2_core_audio_types::AudioTimeStamp;
fn make_idle_capture(input_format: AudioStreamBasicDescription) -> Box<Capture> {
Box::new(Capture {
tap_id: kAudioObjectUnknown,
aggregate_device_id: kAudioObjectUnknown,
io_proc_id: None,
input_format,
running: AtomicBool::new(true),
pcm_callback: None,
pcm_callback_ctx: ptr::null_mut(),
pcm_pool: None,
convert_failures: AtomicU64::new(0),
dropped_buffers: AtomicU64::new(0),
refresher: None,
})
}
fn make_test_pool() -> PcmFramePool {
PcmFramePool::new(2, 8_192).expect("test pool builds")
}
fn call_io_proc(capture: &Capture, abl: &mut AudioBufferList) -> OSStatus {
let mut ts_now: AudioTimeStamp = unsafe { core::mem::zeroed() };
let mut ts_input: AudioTimeStamp = unsafe { core::mem::zeroed() };
let mut ts_output: AudioTimeStamp = unsafe { core::mem::zeroed() };
let mut out_abl = AudioBufferList {
m_number_buffers: 0,
buffers: [AudioBuffer {
m_number_channels: 0,
m_data_byte_size: 0,
m_data: ptr::null_mut(),
}],
};
unsafe {
io_proc(
0,
NonNull::from(&mut ts_now),
NonNull::new(
abl as *mut AudioBufferList as *mut objc2_core_audio_types::AudioBufferList,
)
.expect("input abl non-null"),
NonNull::from(&mut ts_input),
NonNull::new(
&mut out_abl as *mut AudioBufferList
as *mut objc2_core_audio_types::AudioBufferList,
)
.expect("output abl non-null"),
NonNull::from(&mut ts_output),
capture as *const Capture as *mut c_void,
)
}
}
fn interleaved_abl(data: &mut [f32]) -> AudioBufferList {
AudioBufferList {
m_number_buffers: 1,
buffers: [AudioBuffer {
m_number_channels: TARGET_CHANNELS,
m_data_byte_size: (data.len() * size_of::<f32>()) as u32,
m_data: data.as_mut_ptr() as *mut c_void,
}],
}
}
#[test]
fn convert_failures_counted_for_unsupported_format() {
let capture = make_idle_capture(AudioStreamBasicDescription::default());
let mut data = [0.0_f32; 8];
let mut abl = interleaved_abl(&mut data);
let status = call_io_proc(&capture, &mut abl);
assert_eq!(status, NO_ERR);
let diagnostics = capture.diagnostics();
assert_eq!(diagnostics.convert_failures, 1);
assert_eq!(diagnostics.dropped_buffers, 0);
}
unsafe extern "C" fn noop_pcm_cb(
_ctx: *mut c_void,
_slot: *mut Option<PooledPcmFrame>,
_frames: u32,
) {
}
#[test]
fn dropped_buffers_counted_when_pool_exhausted() {
let mut capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
let pool = make_test_pool();
capture.set_pcm_callback(noop_pcm_cb, ptr::null_mut());
capture.set_pcm_pool(pool.clone());
let mut held = Vec::with_capacity(pool.capacity() as usize);
for _ in 0..pool.capacity() {
held.push(pool.try_acquire().expect("slot in capacity"));
}
let mut data = [0.25_f32; 96];
let mut abl = interleaved_abl(&mut data);
let status = call_io_proc(&capture, &mut abl);
drop(held);
assert_eq!(status, NO_ERR);
let diagnostics = capture.diagnostics();
assert_eq!(diagnostics.dropped_buffers, 1);
assert_eq!(diagnostics.convert_failures, 0);
}
#[test]
fn dropped_buffers_counted_when_slot_too_small_for_conversion() {
let mut capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
let pool = PcmFramePool::new(2, 16).expect("tiny pool builds");
capture.set_pcm_callback(noop_pcm_cb, ptr::null_mut());
capture.set_pcm_pool(pool);
let mut data = [0.25_f32; 96];
let mut abl = interleaved_abl(&mut data);
let status = call_io_proc(&capture, &mut abl);
assert_eq!(status, NO_ERR);
let diagnostics = capture.diagnostics();
assert_eq!(diagnostics.dropped_buffers, 1);
assert_eq!(diagnostics.convert_failures, 0);
}
#[test]
fn dropped_buffers_counted_for_oversize_input() {
let capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
let mut data = [0.0_f32; 8];
let bytes_per_frame = (size_of::<f32>() as u32) * TARGET_CHANNELS;
let mut abl = interleaved_abl(&mut data);
abl.buffers[0].m_data_byte_size = (MAX_CALLBACK_INPUT_FRAMES + 1) * bytes_per_frame;
let status = call_io_proc(&capture, &mut abl);
assert_eq!(status, NO_ERR);
let diagnostics = capture.diagnostics();
assert_eq!(diagnostics.dropped_buffers, 1);
assert_eq!(diagnostics.convert_failures, 0);
}
#[test]
fn stopped_capture_counts_nothing() {
let capture = make_idle_capture(AudioStreamBasicDescription::default());
capture.running.store(false, Ordering::Release);
let mut data = [0.0_f32; 8];
let mut abl = interleaved_abl(&mut data);
let status = call_io_proc(&capture, &mut abl);
assert_eq!(status, NO_ERR);
let diagnostics = capture.diagnostics();
assert_eq!(diagnostics.convert_failures, 0);
assert_eq!(diagnostics.dropped_buffers, 0);
}
static OBSERVED_FRAMES: TestAtomicU32 = TestAtomicU32::new(0);
static OBSERVED_FILLED: TestAtomicU32 = TestAtomicU32::new(0);
unsafe extern "C" fn observing_pcm_cb(
_ctx: *mut c_void,
slot: *mut Option<PooledPcmFrame>,
frames: u32,
) {
assert!(!slot.is_null());
let frame = unsafe { (*slot).take() }.expect("slot delivered");
assert_eq!(
frame.filled_len(),
(frames as usize) * (TARGET_CHANNELS as usize)
);
assert_eq!(frame.data_slice()[0], 0.25);
OBSERVED_FILLED.store(frame.filled_len() as u32, Ordering::Release);
OBSERVED_FRAMES.store(frames, Ordering::Release);
}
#[test]
fn successful_conversion_counts_nothing_and_invokes_callback() {
OBSERVED_FRAMES.store(0, Ordering::Release);
OBSERVED_FILLED.store(0, Ordering::Release);
let mut capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
let pool = make_test_pool();
capture.set_pcm_callback(observing_pcm_cb, ptr::null_mut());
capture.set_pcm_pool(pool.clone());
let mut data = [0.25_f32; 96];
let mut abl = interleaved_abl(&mut data);
let status = call_io_proc(&capture, &mut abl);
assert_eq!(status, NO_ERR);
let diagnostics = capture.diagnostics();
assert_eq!(diagnostics.convert_failures, 0);
assert_eq!(diagnostics.dropped_buffers, 0);
assert_eq!(OBSERVED_FRAMES.load(Ordering::Acquire), 48);
assert_eq!(OBSERVED_FILLED.load(Ordering::Acquire), 96);
assert_eq!(pool.stats().in_flight, 0);
assert_eq!(pool.stats().released, 1);
}
#[test]
fn untaken_slot_returns_to_pool() {
let mut capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
let pool = make_test_pool();
capture.set_pcm_callback(noop_pcm_cb, ptr::null_mut());
capture.set_pcm_pool(pool.clone());
let mut data = [0.25_f32; 96];
let mut abl = interleaved_abl(&mut data);
let status = call_io_proc(&capture, &mut abl);
assert_eq!(status, NO_ERR);
assert_eq!(pool.stats().in_flight, 0);
assert_eq!(pool.stats().acquired, 1);
assert_eq!(pool.stats().released, 1);
}
}
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use core::ffi::CStr;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_foundation::{
NSError, NSMutableArray, NSMutableDictionary, NSNumber, NSObject, NSProcessInfo, NSString,
};
pub fn nsstring_from_cstr(s: &CStr) -> Retained<NSString> {
match s.to_str() {
Ok(v) => NSString::from_str(v),
Err(_) => NSString::from_str(""),
}
}
pub fn nsstring_from_str(s: &str) -> Retained<NSString> {
NSString::from_str(s)
}
pub fn nsstring_to_string(s: Option<&NSString>) -> String {
match s {
Some(v) => v.to_string(),
None => String::new(),
}
}
pub fn ns_mutable_array_with_capacity(capacity: usize) -> Retained<NSMutableArray<NSObject>> {
NSMutableArray::<NSObject>::arrayWithCapacity(capacity)
}
pub fn ns_mutable_dictionary_with_capacity() -> Retained<NSMutableDictionary<NSString, NSObject>> {
NSMutableDictionary::<NSString, NSObject>::new()
}
pub fn dict_set_str_key(
dict: &NSMutableDictionary<NSString, NSObject>,
key: &CStr,
value: &NSObject,
) {
let key_ns = nsstring_from_cstr(key);
let key_proto = ProtocolObject::from_ref(&*key_ns);
unsafe { dict.setObject_forKey(value, key_proto) };
}
pub fn operating_system_version_string() -> String {
let info = NSProcessInfo::processInfo();
info.operatingSystemVersionString().to_string()
}
pub fn ns_error_localized_description(err: &NSError) -> String {
err.localizedDescription().to_string()
}
pub fn number_with_unsigned_int(value: u32) -> Retained<NSNumber> {
NSNumber::new_u32(value)
}
pub fn number_with_bool(value: bool) -> Retained<NSNumber> {
NSNumber::new_bool(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nsstring_round_trip() {
let s = nsstring_from_cstr(c"hello fluxer");
assert_eq!("hello fluxer", nsstring_to_string(Some(&s)));
}
#[test]
fn nsstring_to_string_handles_none() {
assert_eq!(String::new(), nsstring_to_string(None));
}
#[test]
fn mutable_array_initial_count_is_zero() {
let arr = ns_mutable_array_with_capacity(4);
assert_eq!(0, arr.count());
}
#[test]
fn mutable_dictionary_initial_count_is_zero() {
let d = ns_mutable_dictionary_with_capacity();
assert_eq!(0, d.count());
}
}
@@ -0,0 +1,36 @@
#![deny(clippy::all)]
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
#![allow(clippy::missing_safety_doc)]
#![allow(clippy::collapsible_if)]
#![allow(clippy::manual_is_multiple_of)]
#![allow(clippy::manual_slice_size_calculation)]
#![allow(clippy::unnecessary_cast)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
#![allow(clippy::missing_transmute_annotations)]
#![allow(clippy::missing_const_for_thread_local)]
#![allow(clippy::too_many_arguments)]
pub mod audio_converter;
pub mod os_version;
pub mod pcm_pool;
pub mod process_tree;
pub mod related_app;
pub mod source_state;
#[cfg(target_os = "macos")]
pub mod audio_source;
#[cfg(target_os = "macos")]
pub mod coreaudio_tap;
#[cfg(target_os = "macos")]
pub mod foundation;
#[cfg(target_os = "macos")]
pub mod sck;
#[cfg(target_os = "macos")]
pub mod sck_async;
#[cfg(target_os = "macos")]
mod napi_surface_macos;
#[cfg(not(target_os = "macos"))]
mod napi_surface_stub;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use napi::bindgen_prelude::{Error, Result, Status};
use napi_derive::napi;
fn unsupported() -> Error {
Error::new(
Status::GenericFailure,
"@fluxer/mac-app-audio is only supported on macOS",
)
}
#[napi(js_name = "pidFromWindowId")]
pub fn pid_from_window_id(_window_id: i64) -> i32 {
0
}
#[napi(js_name = "listAudibleApplications")]
pub fn list_audible_applications() -> Result<Vec<String>> {
Ok(Vec::new())
}
#[napi(js_name = "getBackendAvailability")]
pub fn get_backend_availability() -> Result<()> {
Err(unsupported())
}
#[napi(object, js_name = "MacAppAudioBackendInfo")]
pub struct MacAppAudioBackendInfo {
pub backend: String,
pub supported: bool,
pub reason: String,
#[napi(js_name = "minMacosVersion")]
pub min_macos_version: String,
#[napi(js_name = "minMacosVersionCoreaudio")]
pub min_macos_version_coreaudio: String,
#[napi(js_name = "detectedMacosVersion")]
pub detected_macos_version: Option<String>,
#[napi(js_name = "sckAvailable")]
pub sck_available: bool,
#[napi(js_name = "coreaudioAvailable")]
pub coreaudio_available: bool,
}
#[napi(js_name = "getBackendInfo")]
pub fn get_backend_info() -> MacAppAudioBackendInfo {
use crate::os_version::{COREAUDIO_TAP_MIN_MACOS, SCK_MIN_MACOS, format_version};
MacAppAudioBackendInfo {
backend: "mac-app-audio".to_owned(),
supported: false,
reason: "@fluxer/mac-app-audio is only supported on macOS".to_owned(),
min_macos_version: format_version(SCK_MIN_MACOS),
min_macos_version_coreaudio: format_version(COREAUDIO_TAP_MIN_MACOS),
detected_macos_version: None,
sck_available: false,
coreaudio_available: false,
}
}
#[napi]
pub struct ProcessLoopback;
#[napi]
impl ProcessLoopback {
#[napi(constructor)]
pub fn new() -> Result<Self> {
Err(unsupported())
}
}
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const SCK_MIN_MACOS: (i64, i64, i64) = (12, 3, 0);
pub const COREAUDIO_TAP_MIN_MACOS: (i64, i64, i64) = (14, 2, 0);
pub fn meets_floor(version: (i64, i64, i64), floor: (i64, i64, i64)) -> bool {
if version.0 != floor.0 {
return version.0 > floor.0;
}
if version.1 != floor.1 {
return version.1 > floor.1;
}
version.2 >= floor.2
}
pub fn format_version(version: (i64, i64, i64)) -> String {
if version.2 == 0 {
format!("{}.{}", version.0, version.1)
} else {
format!("{}.{}.{}", version.0, version.1, version.2)
}
}
#[cfg(target_os = "macos")]
pub fn current_macos_version() -> Option<(i64, i64, i64)> {
use objc2_foundation::NSProcessInfo;
let info = NSProcessInfo::processInfo();
let v = info.operatingSystemVersion();
Some((
v.majorVersion as i64,
v.minorVersion as i64,
v.patchVersion as i64,
))
}
#[cfg(not(target_os = "macos"))]
pub fn current_macos_version() -> Option<(i64, i64, i64)> {
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SupportClassification {
pub supported: bool,
pub sck_available: bool,
pub coreaudio_available: bool,
pub reason: String,
}
pub fn classify_support(detected: Option<(i64, i64, i64)>) -> SupportClassification {
let min_sck = format_version(SCK_MIN_MACOS);
let min_coreaudio = format_version(COREAUDIO_TAP_MIN_MACOS);
match detected {
None => SupportClassification {
supported: false,
sck_available: false,
coreaudio_available: false,
reason: "mac-app-audio could not detect the running macOS version. \
Per-app and self-excluding desktop audio capture unavailable."
.to_owned(),
},
Some(v) => {
let detected_str = format_version(v);
let sck_ok = meets_floor(v, SCK_MIN_MACOS);
let coreaudio_ok = meets_floor(v, COREAUDIO_TAP_MIN_MACOS);
let supported = sck_ok || coreaudio_ok;
let reason = if supported {
if coreaudio_ok {
format!(
"mac-app-audio supported on macOS {detected_str} \
(CoreAudio process tap, requires macOS {min_coreaudio}+; \
ScreenCaptureKit fallback requires macOS {min_sck}+)."
)
} else {
format!(
"mac-app-audio supported on macOS {detected_str} \
(ScreenCaptureKit per-app capture, requires macOS {min_sck}+). \
CoreAudio process tap requires macOS {min_coreaudio}+ \
and is unavailable here."
)
}
} else {
format!(
"mac-app-audio requires macOS {min_sck}+ (ScreenCaptureKit). \
This Mac is running macOS {detected_str}. Per-app audio capture \
unavailable; Fluxer must not use a broader audio route that could \
include unrelated apps or call audio."
)
};
SupportClassification {
supported,
sck_available: sck_ok,
coreaudio_available: coreaudio_ok,
reason,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn meets_floor_exact_match() {
assert!(meets_floor((12, 3, 0), SCK_MIN_MACOS));
}
#[test]
fn meets_floor_higher_major() {
assert!(meets_floor((14, 0, 0), SCK_MIN_MACOS));
}
#[test]
fn meets_floor_higher_minor() {
assert!(meets_floor((12, 4, 0), SCK_MIN_MACOS));
}
#[test]
fn rejects_below_floor_minor() {
assert!(!meets_floor((12, 2, 9), SCK_MIN_MACOS));
}
#[test]
fn rejects_below_floor_major() {
assert!(!meets_floor((11, 7, 10), SCK_MIN_MACOS));
}
#[test]
fn rejects_macos_10_15_for_sck() {
assert!(!meets_floor((10, 15, 7), SCK_MIN_MACOS));
}
#[test]
fn coreaudio_floor_14_2() {
assert!(meets_floor((14, 2, 0), COREAUDIO_TAP_MIN_MACOS));
assert!(!meets_floor((14, 1, 9), COREAUDIO_TAP_MIN_MACOS));
assert!(meets_floor((15, 0, 0), COREAUDIO_TAP_MIN_MACOS));
}
#[test]
fn format_version_trims_zero_patch() {
assert_eq!("12.3", format_version((12, 3, 0)));
assert_eq!("14.2.1", format_version((14, 2, 1)));
}
#[test]
fn classify_unknown_version_is_unsupported() {
let c = classify_support(None);
assert!(!c.supported);
assert!(!c.sck_available);
assert!(!c.coreaudio_available);
assert!(c.reason.contains("could not detect"));
}
#[test]
fn classify_macos_10_15_is_unsupported_and_mentions_min_version() {
let c = classify_support(Some((10, 15, 7)));
assert!(!c.supported);
assert!(!c.sck_available);
assert!(!c.coreaudio_available);
assert!(c.reason.contains("macOS 12.3+"), "reason: {}", c.reason);
assert!(c.reason.contains("macOS 10.15.7"), "reason: {}", c.reason);
assert!(c.reason.contains("ScreenCaptureKit"));
}
#[test]
fn classify_macos_12_3_is_sck_only() {
let c = classify_support(Some((12, 3, 0)));
assert!(c.supported);
assert!(c.sck_available);
assert!(!c.coreaudio_available);
assert!(c.reason.contains("ScreenCaptureKit per-app capture"));
assert!(
c.reason
.contains("CoreAudio process tap requires macOS 14.2+")
);
}
#[test]
fn classify_macos_14_2_has_both_backends() {
let c = classify_support(Some((14, 2, 0)));
assert!(c.supported);
assert!(c.sck_available);
assert!(c.coreaudio_available);
assert!(c.reason.contains("CoreAudio process tap"));
}
#[test]
fn classify_macos_15_is_supported() {
let c = classify_support(Some((15, 0, 0)));
assert!(c.supported);
assert!(c.sck_available);
assert!(c.coreaudio_available);
}
}
@@ -0,0 +1,463 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use parking_lot::Mutex;
use std::cell::UnsafeCell;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
pub const PCM_POOL_CAP: usize = 16;
pub const PCM_SLOT_SAMPLES_MAX: usize = 16_384;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PcmPoolError {
ZeroCapacity,
ZeroSamplesPerSlot,
SamplesPerSlotTooLarge(usize),
PayloadTooLarge { offered: usize, capacity: usize },
}
impl fmt::Display for PcmPoolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ZeroCapacity => write!(f, "PcmFramePool capacity must be > 0"),
Self::ZeroSamplesPerSlot => write!(f, "PcmFramePool samples_per_slot must be > 0"),
Self::SamplesPerSlotTooLarge(n) => write!(
f,
"PcmFramePool samples_per_slot {n} exceeds PCM_SLOT_SAMPLES_MAX={PCM_SLOT_SAMPLES_MAX}"
),
Self::PayloadTooLarge { offered, capacity } => write!(
f,
"PcmFramePool payload {offered} samples exceeds slot capacity {capacity}"
),
}
}
}
impl std::error::Error for PcmPoolError {}
struct PcmSlotCell {
inner: UnsafeCell<Box<[f32]>>,
}
unsafe impl Send for PcmSlotCell {}
unsafe impl Sync for PcmSlotCell {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PcmPoolStats {
pub acquired: u64,
pub released: u64,
pub dropped: u64,
pub in_flight: u32,
}
pub(crate) struct PcmFramePoolInner {
slots: Vec<PcmSlotCell>,
free: Mutex<Vec<usize>>,
capacity: u32,
samples_per_slot: u32,
acquired_total: AtomicU64,
released_total: AtomicU64,
dropped_total: AtomicU64,
in_flight: AtomicU32,
}
pub struct PcmFramePool {
inner: Arc<PcmFramePoolInner>,
}
impl fmt::Debug for PcmFramePool {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let stats = self.stats();
f.debug_struct("PcmFramePool")
.field("capacity", &self.inner.capacity)
.field("samples_per_slot", &self.inner.samples_per_slot)
.field("stats", &stats)
.finish()
}
}
impl PcmFramePool {
pub fn new(capacity: usize, samples_per_slot: usize) -> Result<Self, PcmPoolError> {
if capacity == 0 {
return Err(PcmPoolError::ZeroCapacity);
}
if samples_per_slot == 0 {
return Err(PcmPoolError::ZeroSamplesPerSlot);
}
if samples_per_slot > PCM_SLOT_SAMPLES_MAX {
return Err(PcmPoolError::SamplesPerSlotTooLarge(samples_per_slot));
}
assert!(capacity > 0);
assert!(samples_per_slot > 0);
assert!(samples_per_slot <= PCM_SLOT_SAMPLES_MAX);
let mut slots: Vec<PcmSlotCell> = Vec::with_capacity(capacity);
for _ in 0..capacity {
let buf: Box<[f32]> = vec![0.0_f32; samples_per_slot].into_boxed_slice();
assert_eq!(buf.len(), samples_per_slot);
slots.push(PcmSlotCell {
inner: UnsafeCell::new(buf),
});
}
assert_eq!(slots.len(), capacity);
let mut free: Vec<usize> = Vec::with_capacity(capacity);
for index in 0..capacity {
free.push(index);
}
assert_eq!(free.len(), capacity);
let cap_u32 = u32::try_from(capacity).map_err(|_| PcmPoolError::ZeroCapacity)?;
let sps_u32 =
u32::try_from(samples_per_slot).map_err(|_| PcmPoolError::ZeroSamplesPerSlot)?;
let inner = PcmFramePoolInner {
slots,
free: Mutex::new(free),
capacity: cap_u32,
samples_per_slot: sps_u32,
acquired_total: AtomicU64::new(0),
released_total: AtomicU64::new(0),
dropped_total: AtomicU64::new(0),
in_flight: AtomicU32::new(0),
};
Ok(Self {
inner: Arc::new(inner),
})
}
pub fn try_acquire(&self) -> Option<PooledPcmFrame> {
assert!(self.inner.capacity > 0);
assert!(self.inner.samples_per_slot > 0);
let mut free = self.inner.free.lock();
assert!(free.len() <= self.inner.capacity as usize);
let index = match free.pop() {
Some(idx) => idx,
None => {
drop(free);
self.inner.dropped_total.fetch_add(1, Ordering::Relaxed);
return None;
}
};
assert!(index < self.inner.capacity as usize);
self.inner.acquired_total.fetch_add(1, Ordering::Relaxed);
let after = self.inner.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
assert!(after <= self.inner.capacity);
drop(free);
Some(PooledPcmFrame {
slot_index: index,
filled_len: 0,
pool: Arc::clone(&self.inner),
})
}
pub fn capacity(&self) -> u32 {
let cap = self.inner.capacity;
assert!(cap > 0);
assert!(cap as usize == self.inner.slots.len());
cap
}
pub fn samples_per_slot(&self) -> u32 {
let sps = self.inner.samples_per_slot;
assert!(sps > 0);
assert!(sps as usize <= PCM_SLOT_SAMPLES_MAX);
sps
}
pub fn stats(&self) -> PcmPoolStats {
assert!(self.inner.capacity > 0);
let in_flight = self.inner.in_flight.load(Ordering::Acquire);
assert!(in_flight <= self.inner.capacity);
let acquired = self.inner.acquired_total.load(Ordering::Relaxed);
let released = self.inner.released_total.load(Ordering::Relaxed);
let dropped = self.inner.dropped_total.load(Ordering::Relaxed);
assert!(released <= acquired);
PcmPoolStats {
acquired,
released,
dropped,
in_flight,
}
}
}
impl Clone for PcmFramePool {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
pub struct PooledPcmFrame {
slot_index: usize,
filled_len: usize,
pool: Arc<PcmFramePoolInner>,
}
impl PooledPcmFrame {
pub fn write(&mut self, samples: &[f32]) -> Result<(), PcmPoolError> {
assert!(self.slot_index < self.pool.capacity as usize);
let cap = self.pool.samples_per_slot as usize;
if samples.len() > cap {
return Err(PcmPoolError::PayloadTooLarge {
offered: samples.len(),
capacity: cap,
});
}
assert!(samples.len() <= cap);
let cell = &self.pool.slots[self.slot_index];
let buf: &mut [f32] = unsafe { &mut *cell.inner.get() };
assert_eq!(buf.len(), cap);
if !samples.is_empty() {
buf[..samples.len()].copy_from_slice(samples);
}
self.filled_len = samples.len();
assert!(self.filled_len <= cap);
Ok(())
}
pub fn unfilled_mut(&mut self) -> &mut [f32] {
assert!(self.slot_index < self.pool.capacity as usize);
let cap = self.pool.samples_per_slot as usize;
assert!(cap > 0);
let cell = &self.pool.slots[self.slot_index];
let buf: &mut [f32] = unsafe { &mut *cell.inner.get() };
assert_eq!(buf.len(), cap);
buf
}
pub fn set_filled_len(&mut self, len: usize) {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(len <= self.pool.samples_per_slot as usize);
self.filled_len = len;
}
pub fn data_slice(&self) -> &[f32] {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(self.filled_len <= self.pool.samples_per_slot as usize);
let cell = &self.pool.slots[self.slot_index];
let buf: &[f32] = unsafe { &*cell.inner.get() };
&buf[..self.filled_len]
}
pub fn filled_len(&self) -> usize {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(self.filled_len <= self.pool.samples_per_slot as usize);
self.filled_len
}
pub fn capacity(&self) -> usize {
let cap = self.pool.samples_per_slot as usize;
assert!(cap > 0);
assert!(cap <= PCM_SLOT_SAMPLES_MAX);
cap
}
pub fn as_mut_ptr(&mut self) -> *mut f32 {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(self.filled_len <= self.pool.samples_per_slot as usize);
let cell = &self.pool.slots[self.slot_index];
let buf: &mut [f32] = unsafe { &mut *cell.inner.get() };
assert_eq!(buf.len(), self.pool.samples_per_slot as usize);
buf.as_mut_ptr()
}
pub fn into_external_parts(mut self) -> (*mut f32, usize, Self) {
assert!(self.slot_index < self.pool.capacity as usize);
assert!(self.filled_len <= self.pool.samples_per_slot as usize);
let len = self.filled_len;
let ptr = self.as_mut_ptr();
assert!(!ptr.is_null());
(ptr, len, self)
}
}
impl Drop for PooledPcmFrame {
fn drop(&mut self) {
assert!(self.slot_index < self.pool.capacity as usize);
let mut free = self.pool.free.lock();
assert!(free.len() < self.pool.capacity as usize);
free.push(self.slot_index);
let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel);
assert!(before >= 1);
self.pool.released_total.fetch_add(1, Ordering::Relaxed);
drop(free);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
const SLOT_SAMPLES: usize = 2_048;
fn default_pool() -> PcmFramePool {
PcmFramePool::new(PCM_POOL_CAP, SLOT_SAMPLES).expect("default pool builds")
}
#[test]
fn new_rejects_zero_capacity() {
let err = PcmFramePool::new(0, SLOT_SAMPLES).unwrap_err();
assert_eq!(err, PcmPoolError::ZeroCapacity);
}
#[test]
fn new_rejects_zero_samples_per_slot() {
let err = PcmFramePool::new(4, 0).unwrap_err();
assert_eq!(err, PcmPoolError::ZeroSamplesPerSlot);
}
#[test]
fn new_rejects_samples_per_slot_above_max() {
let err = PcmFramePool::new(4, PCM_SLOT_SAMPLES_MAX + 1).unwrap_err();
assert_eq!(
err,
PcmPoolError::SamplesPerSlotTooLarge(PCM_SLOT_SAMPLES_MAX + 1)
);
}
#[test]
fn acquire_release_cycle_increments_counters() {
let pool = PcmFramePool::new(4, SLOT_SAMPLES).expect("pool");
{
let _slot = pool.try_acquire().expect("slot");
let stats_held = pool.stats();
assert_eq!(stats_held.acquired, 1);
assert_eq!(stats_held.in_flight, 1);
}
let stats_after = pool.stats();
assert_eq!(stats_after.acquired, 1);
assert_eq!(stats_after.released, 1);
assert_eq!(stats_after.in_flight, 0);
}
#[test]
fn pool_exhausts_at_cap_and_counts_drop() {
let pool = default_pool();
let mut held = Vec::with_capacity(PCM_POOL_CAP);
for _ in 0..PCM_POOL_CAP {
held.push(pool.try_acquire().expect("slot in capacity"));
}
assert!(pool.try_acquire().is_none());
let stats = pool.stats();
assert_eq!(stats.dropped, 1);
assert_eq!(stats.acquired as usize, PCM_POOL_CAP);
assert_eq!(stats.in_flight as usize, PCM_POOL_CAP);
}
#[test]
fn write_then_data_slice_matches_payload() {
let pool = PcmFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
let payload = [0.25_f32; 32];
slot.write(&payload).expect("payload fits");
assert_eq!(slot.data_slice(), &payload[..]);
assert_eq!(slot.filled_len(), 32);
}
#[test]
fn write_rejects_payload_larger_than_slot() {
let pool = PcmFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
let big = [0.0_f32; 128];
let err = slot.write(&big).unwrap_err();
assert!(matches!(err, PcmPoolError::PayloadTooLarge { .. }));
}
#[test]
fn unfilled_mut_then_set_filled_len_matches_data_slice() {
let pool = PcmFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
{
let buf = slot.unfilled_mut();
assert_eq!(buf.len(), 64);
buf[0] = 0.5;
buf[1] = -0.5;
buf[2] = 1.0;
}
slot.set_filled_len(3);
assert_eq!(slot.filled_len(), 3);
assert_eq!(slot.data_slice(), &[0.5, -0.5, 1.0]);
}
#[test]
#[should_panic]
fn set_filled_len_rejects_overflow() {
let pool = PcmFramePool::new(1, 32).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
slot.set_filled_len(33);
}
#[test]
fn into_external_parts_exposes_filled_pointer_and_length() {
let pool = PcmFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
let payload = [1.5_f32; 16];
slot.write(&payload).expect("write fits");
let (ptr, len, owned) = slot.into_external_parts();
assert!(!ptr.is_null());
assert_eq!(len, 16);
let observed = unsafe { core::slice::from_raw_parts(ptr, len) };
assert_eq!(observed, &payload[..]);
assert_eq!(pool.stats().in_flight, 1);
drop(owned);
assert_eq!(pool.stats().in_flight, 0);
}
#[test]
fn into_external_parts_drop_returns_slot_to_pool() {
let pool = PcmFramePool::new(1, 32).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
slot.write(&[0.75_f32; 8]).expect("write");
let (_ptr, _len, owned) = slot.into_external_parts();
assert!(pool.try_acquire().is_none());
drop(owned);
let revived = pool.try_acquire().expect("revived");
assert_eq!(revived.filled_len(), 0);
drop(revived);
assert_eq!(pool.stats().in_flight, 0);
}
#[test]
fn pooled_frame_survives_send_across_threads() {
let pool = PcmFramePool::new(2, 64).expect("pool");
let mut slot = pool.try_acquire().expect("slot");
slot.write(&[0.5_f32; 32]).expect("write");
let handle = thread::spawn(move || {
assert_eq!(slot.filled_len(), 32);
assert_eq!(slot.data_slice()[0], 0.5);
drop(slot);
});
handle.join().expect("worker");
assert_eq!(pool.stats().in_flight, 0);
assert_eq!(pool.stats().released, 1);
}
#[test]
fn capacity_one_pool_round_trips() {
let pool = PcmFramePool::new(1, 32).expect("pool");
for _ in 0..5 {
let mut slot = pool.try_acquire().expect("slot");
slot.write(&[1.0, 2.0, 3.0]).expect("write");
assert_eq!(slot.data_slice(), &[1.0, 2.0, 3.0]);
drop(slot);
}
let stats = pool.stats();
assert_eq!(stats.acquired, 5);
assert_eq!(stats.released, 5);
assert_eq!(stats.in_flight, 0);
}
#[test]
fn default_pool_dimensions_match_constants() {
let pool = default_pool();
assert_eq!(pool.capacity() as usize, PCM_POOL_CAP);
assert_eq!(pool.samples_per_slot() as usize, SLOT_SAMPLES);
}
}
@@ -0,0 +1,363 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct Info {
pub pid: i32,
pub parent_pid: i32,
pub process_group_id: i32,
}
pub type ResolverFn = fn(ctx: Option<&dyn ResolverCtx>, pid: i32) -> Option<Info>;
pub trait ResolverCtx {
fn resolve(&self, pid: i32) -> Option<Info>;
}
#[cfg(target_os = "macos")]
mod sys {
use super::Info;
use std::mem::{offset_of, size_of};
const PROC_PIDTBSDINFO: i32 = 3;
const MAXCOMLEN: usize = 16;
#[repr(C)]
#[derive(Default, Copy, Clone)]
pub struct ProcBsdInfo {
pub pbi_flags: u32,
pub pbi_status: u32,
pub pbi_xstatus: u32,
pub pbi_pid: u32,
pub pbi_ppid: u32,
pub pbi_uid: u32,
pub pbi_gid: u32,
pub pbi_ruid: u32,
pub pbi_rgid: u32,
pub pbi_svuid: u32,
pub pbi_svgid: u32,
pub rfu_1: u32,
pub pbi_comm: [u8; MAXCOMLEN],
pub pbi_name: [u8; 2 * MAXCOMLEN],
pub pbi_nfiles: u32,
pub pbi_pgid: u32,
pub pbi_pjobc: u32,
pub e_tdev: u32,
pub e_tpgid: u32,
pub pbi_nice: i32,
pub pbi_start_tvsec: u64,
pub pbi_start_tvusec: u64,
}
unsafe extern "C" {
fn proc_pidinfo(
pid: i32,
flavor: i32,
arg: u64,
buffer: *mut core::ffi::c_void,
buffersize: i32,
) -> i32;
pub fn proc_listallpids(buffer: *mut core::ffi::c_void, buffersize: i32) -> i32;
}
pub fn info_for_pid(pid: i32) -> Option<Info> {
if pid <= 0 {
return None;
}
let mut raw = ProcBsdInfo::default();
let copied = unsafe {
proc_pidinfo(
pid,
PROC_PIDTBSDINFO,
0,
(&raw mut raw) as *mut _ as *mut _,
size_of::<ProcBsdInfo>() as i32,
)
};
let min = (offset_of!(ProcBsdInfo, pbi_pgid) + size_of::<u32>()) as i32;
if copied < min {
return None;
}
Some(Info {
pid: raw.pbi_pid as i32,
parent_pid: raw.pbi_ppid as i32,
process_group_id: raw.pbi_pgid as i32,
})
}
}
#[cfg(target_os = "macos")]
pub fn info_for_pid(pid: i32) -> Option<Info> {
sys::info_for_pid(pid)
}
#[cfg(not(target_os = "macos"))]
pub fn info_for_pid(_pid: i32) -> Option<Info> {
None
}
fn live_resolver(_ctx: Option<&dyn ResolverCtx>, pid: i32) -> Option<Info> {
info_for_pid(pid)
}
pub fn is_same_launch_tree(candidate_pid: i32, target_pid: i32, target_info: Option<Info>) -> bool {
is_same_launch_tree_with_resolver(candidate_pid, target_pid, target_info, None, live_resolver)
}
pub fn is_same_launch_tree_with_resolver(
candidate_pid: i32,
target_pid: i32,
target_info: Option<Info>,
ctx: Option<&dyn ResolverCtx>,
resolver: ResolverFn,
) -> bool {
if candidate_pid <= 0 || target_pid <= 0 {
return false;
}
if candidate_pid == target_pid {
return true;
}
let mut current = match resolver(ctx, candidate_pid) {
Some(i) => i,
None => return false,
};
if shares_process_group(current, target_pid, target_info) {
return true;
}
let mut depth = 0usize;
while depth < 64 {
let parent = current.parent_pid;
if parent == target_pid {
return true;
}
if parent <= 1 || parent == current.pid {
return false;
}
current = match resolver(ctx, parent) {
Some(i) => i,
None => return false,
};
depth += 1;
}
false
}
fn shares_process_group(candidate: Info, target_pid: i32, target_info: Option<Info>) -> bool {
let cg = candidate.process_group_id;
if cg <= 0 {
return false;
}
if cg == target_pid {
return true;
}
if let Some(t) = target_info {
return t.process_group_id > 0 && cg == t.process_group_id;
}
false
}
fn append_pid(out: &mut Vec<i32>, max: usize, pid: i32) -> bool {
if out.contains(&pid) {
return true;
}
if out.len() >= max {
return false;
}
out.push(pid);
true
}
pub fn collect_related_pids_with_resolver(
target_pid: i32,
target_info: Option<Info>,
candidates: &[i32],
max_count: usize,
ctx: Option<&dyn ResolverCtx>,
resolver: ResolverFn,
) -> Vec<i32> {
if target_pid <= 0 || max_count == 0 {
return Vec::new();
}
let cap = candidates.len().max(1).min(max_count);
let mut out: Vec<i32> = Vec::with_capacity(cap);
let _ = append_pid(&mut out, cap, target_pid);
for &pid in candidates {
if out.len() >= cap {
break;
}
if pid <= 0 || pid == target_pid {
continue;
}
if !is_same_launch_tree_with_resolver(pid, target_pid, target_info, ctx, resolver) {
continue;
}
let _ = append_pid(&mut out, cap, pid);
}
out
}
#[cfg(target_os = "macos")]
pub fn collect_related_pids(target_pid: i32, max_count: usize) -> Vec<i32> {
if target_pid <= 0 {
return Vec::new();
}
let reported = unsafe { sys::proc_listallpids(core::ptr::null_mut(), 0) };
if reported <= 0 {
return vec![target_pid];
}
let mut all_pids: Vec<i32> = vec![0; reported as usize];
let copied = unsafe {
sys::proc_listallpids(
all_pids.as_mut_ptr() as *mut _,
(all_pids.len() * core::mem::size_of::<i32>()) as i32,
)
};
if copied <= 0 {
return vec![target_pid];
}
let count = (copied as usize).min(all_pids.len());
collect_related_pids_with_resolver(
target_pid,
info_for_pid(target_pid),
&all_pids[..count],
max_count,
None,
live_resolver,
)
}
#[cfg(not(target_os = "macos"))]
pub fn collect_related_pids(target_pid: i32, _max_count: usize) -> Vec<i32> {
if target_pid <= 0 {
return Vec::new();
}
vec![target_pid]
}
#[cfg(test)]
mod tests {
use super::*;
struct StaticCtx<'a> {
infos: &'a [Info],
}
impl<'a> ResolverCtx for StaticCtx<'a> {
fn resolve(&self, pid: i32) -> Option<Info> {
self.infos.iter().copied().find(|i| i.pid == pid)
}
}
fn ctx_resolver(ctx: Option<&dyn ResolverCtx>, pid: i32) -> Option<Info> {
ctx?.resolve(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,
},
];
let ctx = StaticCtx { infos: &infos };
assert!(is_same_launch_tree_with_resolver(
101,
100,
Some(infos[0]),
Some(&ctx),
ctx_resolver
));
}
#[test]
fn same_launch_tree_includes_pgrp_peer() {
let infos = [
Info {
pid: 200,
parent_pid: 1,
process_group_id: 200,
},
Info {
pid: 201,
parent_pid: 1,
process_group_id: 200,
},
];
let ctx = StaticCtx { infos: &infos };
assert!(is_same_launch_tree_with_resolver(
201,
200,
Some(infos[0]),
Some(&ctx),
ctx_resolver
));
}
#[test]
fn same_launch_tree_excludes_unrelated() {
let infos = [
Info {
pid: 300,
parent_pid: 1,
process_group_id: 300,
},
Info {
pid: 301,
parent_pid: 1,
process_group_id: 301,
},
];
let ctx = StaticCtx { infos: &infos };
assert!(!is_same_launch_tree_with_resolver(
301,
300,
Some(infos[0]),
Some(&ctx),
ctx_resolver
));
}
#[test]
fn collect_related_returns_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 = [500i32, 401, 402, 400];
let ctx = StaticCtx { infos: &infos };
let pids = collect_related_pids_with_resolver(
400,
Some(infos[0]),
&candidates,
8,
Some(&ctx),
ctx_resolver,
);
assert_eq!(pids, vec![400, 401, 402]);
}
}
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub fn trim_ascii_whitespace(value: &str) -> &str {
value.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\r' | '\n'))
}
pub fn has_prefix_with_suffix(value: &str, prefix: &str, suffix: &str) -> bool {
let pb = prefix.as_bytes();
let sb = suffix.as_bytes();
let vb = value.as_bytes();
if vb.len() < pb.len() + sb.len() {
return false;
}
&vb[..pb.len()] == pb && &vb[pb.len()..pb.len() + sb.len()] == sb
}
pub fn related_by_prefix_either_way(a: &str, b: &str, suffix: &str) -> bool {
has_prefix_with_suffix(a, b, suffix) || has_prefix_with_suffix(b, a, suffix)
}
pub fn helper_bundle_base(value: &str) -> &str {
let suffixes = [".helper", ".Helper", "-helper", "-Helper"];
for suffix in suffixes {
if let Some(idx) = value.rfind(suffix) {
if idx == 0 {
continue;
}
if value[..idx].find('.').is_none() {
continue;
}
let after = idx + suffix.len();
let bytes = value.as_bytes();
if after == bytes.len() || bytes[after] == b'.' || bytes[after] == b'-' {
return &value[..idx];
}
}
}
value
}
pub fn helper_name_base(value: &str) -> &str {
let trimmed = trim_ascii_whitespace(value);
if let Some(idx) = trimmed.find(" Helper") {
if idx > 0 {
return trim_ascii_whitespace(&trimmed[..idx]);
}
}
trimmed
}
pub fn looks_related_by_strings(
candidate_bundle: &str,
target_bundle: &str,
candidate_name: &str,
target_name: &str,
) -> bool {
if !target_bundle.is_empty() && !candidate_bundle.is_empty() {
let tb = helper_bundle_base(target_bundle);
let cb = helper_bundle_base(candidate_bundle);
if candidate_bundle == target_bundle
|| cb == tb
|| related_by_prefix_either_way(candidate_bundle, target_bundle, ".")
|| related_by_prefix_either_way(candidate_bundle, target_bundle, "-")
{
return true;
}
}
let tn = trim_ascii_whitespace(target_name);
let cn = trim_ascii_whitespace(candidate_name);
if !tn.is_empty() {
let tnb = helper_name_base(tn);
let cnb = helper_name_base(cn);
if cn == tn
|| cnb == tnb
|| related_by_prefix_either_way(cn, tn, " ")
|| related_by_prefix_either_way(cn, tn, " Helper")
{
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn helper_bundle_strips_suffix() {
assert_eq!(
"com.example.app",
helper_bundle_base("com.example.app.helper")
);
assert_eq!(
"com.example.app",
helper_bundle_base("com.example.app.Helper")
);
assert_eq!(
"com.example.app",
helper_bundle_base("com.example.app-helper")
);
assert_eq!("helper", helper_bundle_base("helper"));
assert_eq!(
"com.example.app",
helper_bundle_base("com.example.app.helper.Plugin")
);
}
#[test]
fn helper_name_strips_helper_suffix() {
assert_eq!("Example", helper_name_base("Example Helper"));
assert_eq!("Example", helper_name_base("Example Helper (Renderer)"));
assert_eq!("Example", helper_name_base(" Example "));
assert_eq!("Foo", helper_name_base("Foo"));
}
#[test]
fn related_via_bundle_helper_base() {
assert!(looks_related_by_strings(
"com.example.app.helper",
"com.example.app",
"",
""
));
assert!(looks_related_by_strings(
"com.example.app",
"com.example.app.Helper",
"",
""
));
}
#[test]
fn related_via_name_helper_base() {
assert!(looks_related_by_strings(
"",
"",
"Example Helper",
"Example"
));
}
#[test]
fn unrelated_returns_false() {
assert!(!looks_related_by_strings(
"com.firefox.app",
"com.chrome.app",
"Firefox",
"Chrome"
));
}
#[test]
fn related_by_dot_prefix() {
assert!(looks_related_by_strings(
"com.example.app.renderer",
"com.example.app",
"",
""
));
}
}
@@ -0,0 +1,181 @@
#![allow(non_snake_case)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use objc2::rc::Retained;
use objc2_core_foundation::CGRect;
use objc2_core_media::{CMTime, CMTimeFlags};
use objc2_foundation::NSString;
use objc2_screen_capture_kit::{
SCDisplay, SCRunningApplication, SCStream, SCStreamConfiguration, SCWindow,
};
pub use objc2_screen_capture_kit::SCStreamOutputType;
pub fn cgrect_standardized(r: CGRect) -> CGRect {
let mut out = r;
if out.size.width < 0.0 {
out.origin.x += out.size.width;
out.size.width = -out.size.width;
}
if out.size.height < 0.0 {
out.origin.y += out.size.height;
out.size.height = -out.size.height;
}
out
}
pub fn cgrect_intersection_area(a_raw: CGRect, b_raw: CGRect) -> f64 {
let a = cgrect_standardized(a_raw);
let b = cgrect_standardized(b_raw);
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)
}
pub fn cmtime_seconds(value: i64, timescale: i32) -> CMTime {
CMTime {
value,
timescale,
flags: CMTimeFlags(1),
epoch: 0,
}
}
pub fn sc_running_application_process_id(app: &SCRunningApplication) -> i32 {
unsafe { app.processID() }
}
pub fn sc_running_application_bundle_identifier(app: &SCRunningApplication) -> Retained<NSString> {
unsafe { app.bundleIdentifier() }
}
pub fn sc_running_application_name(app: &SCRunningApplication) -> Retained<NSString> {
unsafe { app.applicationName() }
}
pub fn sc_display_frame(display: &SCDisplay) -> CGRect {
unsafe { display.frame() }
}
pub fn sc_window_owning_application(win: &SCWindow) -> Option<Retained<SCRunningApplication>> {
unsafe { win.owningApplication() }
}
pub fn sc_window_frame(win: &SCWindow) -> CGRect {
unsafe { win.frame() }
}
pub fn cfg_set_captures_audio(cfg: &SCStreamConfiguration, v: bool) {
unsafe { cfg.setCapturesAudio(v) }
}
pub fn cfg_set_excludes_current_process_audio(cfg: &SCStreamConfiguration, v: bool) {
unsafe { cfg.setExcludesCurrentProcessAudio(v) }
}
pub fn cfg_set_sample_rate(cfg: &SCStreamConfiguration, v: isize) {
unsafe { cfg.setSampleRate(v) }
}
pub fn cfg_set_channel_count(cfg: &SCStreamConfiguration, v: isize) {
unsafe { cfg.setChannelCount(v) }
}
pub fn cfg_set_queue_depth(cfg: &SCStreamConfiguration, v: isize) {
unsafe { cfg.setQueueDepth(v) }
}
pub fn cfg_set_width(cfg: &SCStreamConfiguration, v: usize) {
unsafe { cfg.setWidth(v) }
}
pub fn cfg_set_height(cfg: &SCStreamConfiguration, v: usize) {
unsafe { cfg.setHeight(v) }
}
pub fn cfg_set_shows_cursor(cfg: &SCStreamConfiguration, v: bool) {
unsafe { cfg.setShowsCursor(v) }
}
pub fn cfg_set_minimum_frame_interval(cfg: &SCStreamConfiguration, t: CMTime) {
unsafe { cfg.setMinimumFrameInterval(t) }
}
pub fn cfg_set_capture_dynamic_range_sdr_if_available(cfg: &SCStreamConfiguration) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setCaptureDynamicRange:)) {
unsafe {
cfg.setCaptureDynamicRange(objc2_screen_capture_kit::SCCaptureDynamicRange(0));
}
}
}
pub fn cfg_set_stream_name_if_available(cfg: &SCStreamConfiguration, name: &NSString) {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
let obj: &objc2::runtime::NSObject = cfg.as_ref();
if obj.respondsToSelector(sel!(setStreamName:)) {
unsafe {
cfg.setStreamName(Some(name));
}
}
}
pub fn sc_stream_add_stream_output(
stream: &SCStream,
output: &objc2::runtime::ProtocolObject<dyn objc2_screen_capture_kit::SCStreamOutput>,
kind: SCStreamOutputType,
queue: Option<&dispatch2::DispatchQueue>,
) -> Result<(), Retained<objc2_foundation::NSError>> {
unsafe { stream.addStreamOutput_type_sampleHandlerQueue_error(output, kind, queue) }
}
#[cfg(test)]
mod tests {
use super::*;
use objc2_core_foundation::{CGPoint, CGSize};
#[test]
fn cgrect_intersection_handles_negative_and_disjoint() {
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));
}
}
@@ -0,0 +1,205 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::time::Duration;
use block2::RcBlock;
use objc2::rc::Retained;
use objc2_foundation::NSError;
use objc2_screen_capture_kit::{SCShareableContent, SCStream};
pub const DEFAULT_TIMEOUT_NS: u64 = 30 * 1_000_000_000;
#[derive(Debug, Eq, PartialEq)]
pub enum AsyncError {
AsyncTimedOut,
SCKReturnedError,
}
struct WakerInner {
state: Mutex<WakerState>,
cv: Condvar,
}
struct WakerState {
done: bool,
failed: bool,
err: Option<Retained<NSError>>,
content: Option<Retained<SCShareableContent>>,
}
unsafe impl Send for WakerInner {}
unsafe impl Sync for WakerInner {}
fn new_waker() -> Arc<WakerInner> {
Arc::new(WakerInner {
state: Mutex::new(WakerState {
done: false,
failed: false,
err: None,
content: None,
}),
cv: Condvar::new(),
})
}
fn lock_state(w: &WakerInner) -> MutexGuard<'_, WakerState> {
w.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn wait_deadline(w: &Arc<WakerInner>, timeout_ns: u64) -> bool {
let s = lock_state(w);
if s.done {
return true;
}
let dur = Duration::from_nanos(timeout_ns);
match w.cv.wait_timeout(s, dur) {
Ok((state, _)) => state.done,
Err(poisoned) => poisoned.into_inner().0.done,
}
}
fn retain_error(err: *mut NSError) -> Result<Option<Retained<NSError>>, AsyncError> {
if err.is_null() {
Ok(None)
} else {
unsafe { Retained::retain(err) }
.map(Some)
.ok_or(AsyncError::SCKReturnedError)
}
}
fn retain_content(
content: *mut SCShareableContent,
) -> Result<Option<Retained<SCShareableContent>>, AsyncError> {
if content.is_null() {
Ok(None)
} else {
unsafe { Retained::retain(content) }
.map(Some)
.ok_or(AsyncError::SCKReturnedError)
}
}
fn complete(
waker: &WakerInner,
err: Option<Retained<NSError>>,
content: Option<Retained<SCShareableContent>>,
failed: bool,
) {
let mut s = lock_state(waker);
s.err = err;
s.content = content;
s.failed = failed;
s.done = true;
waker.cv.notify_all();
}
pub fn await_ns_error_block_start(stream: &SCStream, timeout_ns: u64) -> Result<(), AsyncError> {
let waker = new_waker();
let waker_cb = waker.clone();
let blk = RcBlock::new(move |err: *mut NSError| {
let (err_opt, failed) = match retain_error(err) {
Ok(err_opt) => (err_opt, false),
Err(_) => (None, true),
};
complete(&waker_cb, err_opt, None, failed);
});
unsafe {
stream.startCaptureWithCompletionHandler(Some(&blk));
}
if !wait_deadline(&waker, timeout_ns) {
return Err(AsyncError::AsyncTimedOut);
}
let s = lock_state(&waker);
if s.failed || s.err.is_some() {
return Err(AsyncError::SCKReturnedError);
}
Ok(())
}
pub fn await_ns_error_block_stop(stream: &SCStream, timeout_ns: u64) -> Result<(), AsyncError> {
let waker = new_waker();
let waker_cb = waker.clone();
let blk = RcBlock::new(move |err: *mut NSError| {
let (err_opt, failed) = match retain_error(err) {
Ok(err_opt) => (err_opt, false),
Err(_) => (None, true),
};
complete(&waker_cb, err_opt, None, failed);
});
unsafe {
stream.stopCaptureWithCompletionHandler(Some(&blk));
}
if !wait_deadline(&waker, timeout_ns) {
return Err(AsyncError::AsyncTimedOut);
}
let s = lock_state(&waker);
if s.failed || s.err.is_some() {
return Err(AsyncError::SCKReturnedError);
}
Ok(())
}
pub fn start_capture(stream: &SCStream, timeout_ns: u64) -> Result<(), AsyncError> {
await_ns_error_block_start(stream, timeout_ns)
}
pub fn stop_capture(stream: &SCStream, timeout_ns: u64) -> Result<(), AsyncError> {
await_ns_error_block_stop(stream, timeout_ns)
}
pub struct ShareableContent {
pub content: Retained<SCShareableContent>,
}
pub fn get_shareable_content(
excluding_desktop_windows: bool,
on_screen_windows_only: bool,
timeout_ns: u64,
) -> Result<ShareableContent, AsyncError> {
let waker = new_waker();
let waker_cb = waker.clone();
let blk = RcBlock::new(move |content: *mut SCShareableContent, err: *mut NSError| {
let (err_opt, err_failed) = match retain_error(err) {
Ok(err_opt) => (err_opt, false),
Err(_) => (None, true),
};
let (content_opt, content_failed) = match retain_content(content) {
Ok(content_opt) => (content_opt, false),
Err(_) => (None, true),
};
complete(
&waker_cb,
err_opt,
content_opt,
err_failed || content_failed,
);
});
unsafe {
SCShareableContent::getShareableContentExcludingDesktopWindows_onScreenWindowsOnly_completionHandler(
excluding_desktop_windows,
on_screen_windows_only,
&blk,
);
}
if !wait_deadline(&waker, timeout_ns) {
return Err(AsyncError::AsyncTimedOut);
}
let mut s = lock_state(&waker);
if s.failed || s.err.is_some() {
return Err(AsyncError::SCKReturnedError);
}
match s.content.take() {
Some(content) => Ok(ShareableContent { content }),
None => Err(AsyncError::SCKReturnedError),
}
}
@@ -0,0 +1,230 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicU32, Ordering};
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u32)]
pub enum State {
Idle = 0,
Starting = 1,
Running = 2,
Stopping = 3,
Stopped = 4,
}
impl State {
fn from_u32(v: u32) -> State {
match v {
0 => State::Idle,
1 => State::Starting,
2 => State::Running,
3 => State::Stopping,
_ => State::Stopped,
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
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 => matches!(to, State::Stopped),
State::Stopped => false,
}
}
pub struct Machine {
state: AtomicU32,
}
impl Machine {
pub fn new() -> Self {
Self {
state: AtomicU32::new(State::Idle as u32),
}
}
pub fn current(&self) -> State {
State::from_u32(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_u32(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;
}
}
}
}
impl Default for Machine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_allowed_exhaustive() {
let all = [
State::Idle,
State::Starting,
State::Running,
State::Stopping,
State::Stopped,
];
let allowed: &[(State, State)] = &[
(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 {
let expected = allowed.iter().any(|p| p.0 == from && p.1 == to);
assert_eq!(expected, is_allowed(from, to), "{:?} -> {:?}", from, to);
}
}
}
#[test]
fn happy_path() {
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() {
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() {
for &start in &[
State::Idle,
State::Starting,
State::Running,
State::Stopping,
] {
let m = Machine::new();
m.state.store(start as u32, Ordering::Release);
let prev = m.mark_fatal();
assert_eq!(start, prev);
assert_eq!(State::Stopped, m.current());
}
}
#[test]
fn mark_fatal_idempotent() {
let m = Machine::new();
m.state.store(State::Stopped as u32, Ordering::Release);
let prev = m.mark_fatal();
assert_eq!(State::Stopped, prev);
assert_eq!(State::Stopped, m.current());
}
}