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:
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, ¤t, 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user