// SPDX-License-Identifier: AGPL-3.0-or-later use std::ffi::c_void; use std::ptr; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex, MutexGuard, Weak}; use std::time::Duration; use block2::RcBlock; use napi::bindgen_prelude::{Buffer, BufferSlice, Function, Result, Unknown}; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi::{JsValue, Status, ValueType}; use napi_derive::napi; use objc2::rc::Retained; use objc2::runtime::{NSObject, NSObjectProtocol, ProtocolObject}; use objc2::{AllocAnyThread, DefinedClass, define_class, msg_send}; use objc2_core_audio_types::{ AudioBufferList, AudioStreamBasicDescription, kAudioFormatFlagIsFloat, kAudioFormatFlagIsNonInterleaved, kAudioFormatFlagIsSignedInteger, kAudioFormatLinearPCM, }; use objc2_core_foundation::{CFRetained, CGPoint, CGRect, CGSize}; use objc2_core_media::{ CMAudioFormatDescriptionGetStreamBasicDescription, CMBlockBuffer, CMSampleBuffer, }; use objc2_core_video::{ CVPixelBuffer, CVPixelBufferGetHeight, CVPixelBufferGetIOSurface, CVPixelBufferGetPixelFormatType, CVPixelBufferGetPlaneCount, CVPixelBufferGetWidth, CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, kCVPixelFormatType_32BGRA, }; use objc2_foundation::{NSArray, NSError, NSString}; use objc2_screen_capture_kit::{ SCContentFilter, SCDisplay, SCShareableContent, SCStream, SCStreamConfiguration, SCStreamDelegate, SCStreamOutput, SCStreamOutputType, SCWindow, }; use fluxer_screen_frame_bus::{ self as frame_bus, EnqueueOutcome, MacCvPixelBufferFrame, NativeScreenFrameSinkHandle, NativeScreenFrameSinkHandleRef, ScreenFrame as BusScreenFrame, }; use crate::audio_pool::{ MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT, MacAudioFramePool, PooledMacAudioFrame, }; use crate::config::{ AUDIO_CHANNEL_COUNT_DEFAULT, AUDIO_SAMPLE_RATE_DEFAULT_HZ, AudioSampleFormat, CaptureFailureSurface, SckCaptureConfig, SckCaptureFailure, SckColorSpace, SckPixelFormat, }; use crate::encoder_attach::EncoderAttachment; use crate::foundation; use crate::os_version::{ self, SCK_MIN_MACOS, SupportClassification, classify_support, format_version, }; use crate::permission_probe::{ PROBE_TIMEOUT_NS, ShareableContentOutcome, screen_recording_probe_result, }; use crate::sck; use fluxer_encoder_ring::EncoderFrameRate; const DEFAULT_TIMEOUT_NS: u64 = 30 * 1_000_000_000; const PIXEL_FORMAT_420V: u32 = u32::from_be_bytes(*b"420v"); const PIXEL_FORMAT_420F: u32 = u32::from_be_bytes(*b"420f"); #[link(name = "CoreGraphics", kind = "framework")] unsafe extern "C" { fn CGPreflightScreenCaptureAccess() -> bool; } #[link(name = "CoreVideo", kind = "framework")] unsafe extern "C" { fn CVPixelBufferRetain(pixel_buffer: *mut c_void) -> *mut c_void; fn CVPixelBufferRelease(pixel_buffer: *mut c_void); } #[derive(Debug, Eq, PartialEq)] enum AsyncError { Timeout, SckErr, } struct WakerInner { state: Mutex, cv: Condvar, } struct WakerState { done: bool, failed: bool, err: Option>, content: Option>, } unsafe impl Send for WakerInner {} unsafe impl Sync for WakerInner {} fn new_waker() -> Arc { Arc::new(WakerInner { state: Mutex::new(WakerState { done: false, failed: false, err: None, content: None, }), cv: Condvar::new(), }) } fn lock_waker_state(w: &WakerInner) -> MutexGuard<'_, WakerState> { w.state .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn wait_deadline(w: &Arc, timeout_ns: u64) -> bool { let s = lock_waker_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) -> std::result::Result>, AsyncError> { if err.is_null() { Ok(None) } else { unsafe { Retained::retain(err) } .map(Some) .ok_or(AsyncError::SckErr) } } fn retain_content( content: *mut SCShareableContent, ) -> std::result::Result>, AsyncError> { if content.is_null() { Ok(None) } else { unsafe { Retained::retain(content) } .map(Some) .ok_or(AsyncError::SckErr) } } fn complete_waker( waker: &WakerInner, err: Option>, content: Option>, failed: bool, ) { let mut s = lock_waker_state(waker); s.err = err; s.content = content; s.failed = failed; s.done = true; waker.cv.notify_all(); } fn await_start(stream: &SCStream, timeout_ns: u64) -> std::result::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(opt) => (opt, false), Err(_) => (None, true), }; complete_waker(&waker_cb, err_opt, None, failed); }); unsafe { stream.startCaptureWithCompletionHandler(Some(&blk)); } if !wait_deadline(&waker, timeout_ns) { return Err(AsyncError::Timeout); } let s = lock_waker_state(&waker); if s.failed || s.err.is_some() { return Err(AsyncError::SckErr); } Ok(()) } fn await_stop(stream: &SCStream, timeout_ns: u64) -> std::result::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(opt) => (opt, false), Err(_) => (None, true), }; complete_waker(&waker_cb, err_opt, None, failed); }); unsafe { stream.stopCaptureWithCompletionHandler(Some(&blk)); } if !wait_deadline(&waker, timeout_ns) { return Err(AsyncError::Timeout); } let s = lock_waker_state(&waker); if s.failed || s.err.is_some() { return Err(AsyncError::SckErr); } Ok(()) } fn get_shareable_content( timeout_ns: u64, ) -> std::result::Result, 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(opt) => (opt, false), Err(_) => (None, true), }; let (content_opt, content_failed) = match retain_content(content) { Ok(opt) => (opt, false), Err(_) => (None, true), }; complete_waker( &waker_cb, err_opt, content_opt, err_failed || content_failed, ); }); unsafe { SCShareableContent::getShareableContentExcludingDesktopWindows_onScreenWindowsOnly_completionHandler( false, false, &blk, ); } if !wait_deadline(&waker, timeout_ns) { return Err(AsyncError::Timeout); } let mut s = lock_waker_state(&waker); if s.failed || s.err.is_some() { return Err(AsyncError::SckErr); } s.content.take().ok_or(AsyncError::SckErr) } type LifecycleTsfn = ThreadsafeFunction<(String, String), (), (String, String), Status, false, false, 8>; type AudioFrameTsfnArgs = (u32, u32, u32, i64, u32, Buffer); type AudioFrameTsfnInput = (u32, u32, u32, i64, AudioSampleFormat, PooledMacAudioFrame); type AudioFrameTsfn = ThreadsafeFunction; type AudioDiagnosticTsfnArgs = (String, String, u32, u32); type AudioDiagnosticTsfn = ThreadsafeFunction< AudioDiagnosticTsfnArgs, (), AudioDiagnosticTsfnArgs, Status, false, false, 8, >; type EncoderDiagnosticTsfnArgs = (String, String); type EncoderDiagnosticTsfn = ThreadsafeFunction< EncoderDiagnosticTsfnArgs, (), EncoderDiagnosticTsfnArgs, Status, false, false, 8, >; struct AudioFramePayload { sample_rate_hz: u32, channels: u32, num_samples_per_channel: u32, pts_us: i64, format: AudioSampleFormat, slot: PooledMacAudioFrame, } impl AudioFramePayload { fn into_input(self) -> AudioFrameTsfnInput { assert!(self.sample_rate_hz > 0); assert!(self.channels > 0); ( self.sample_rate_hz, self.channels, self.num_samples_per_channel, self.pts_us, self.format, self.slot, ) } } struct CaptureState { lifecycle_tsfn: Option, audio_diagnostic_tsfn: Option, encoder_diagnostic_tsfn: Option, delegate: Option>, stream: Option>, sample_queue: Option>, } unsafe impl Send for CaptureState {} unsafe impl Sync for CaptureState {} pub(crate) struct CaptureInner { state: Mutex, audio_frame_tsfn: parking_lot::RwLock>, running: AtomicBool, tsfn_aborted: AtomicBool, capture_id: Mutex>, bus_sink: parking_lot::RwLock>>, native_frame_sink: parking_lot::RwLock>>, failure_surface: parking_lot::RwLock>>, captures_audio: AtomicBool, audio_sample_rate_hz: AtomicU32, audio_channels: AtomicU32, audio_pool: MacAudioFramePool, encoder_attachment: parking_lot::RwLock>>, encoder_attach_requested: AtomicBool, encoder_ring_full_emitted: AtomicBool, frame_sink_accepted: AtomicU64, frame_sink_coalesced: AtomicU64, frame_sink_rejected: AtomicU64, media_frames_dropped_without_sink: AtomicU64, frame_sink_backpressure_emitted: AtomicBool, frame_sink_missing_emitted: AtomicBool, } fn generic_error(reason: impl Into) -> napi::Error { napi::Error::new(Status::GenericFailure, reason.into()) } fn invalid_arg(reason: impl Into) -> napi::Error { napi::Error::new(Status::InvalidArg, reason.into()) } fn lock_state(inner: &CaptureInner) -> Result> { inner .state .lock() .map_err(|_| generic_error("ScreenCapture state lock poisoned")) } fn note_tsfn_status(inner: &CaptureInner, status: Status) { if status == Status::Closing { inner.tsfn_aborted.store(true, Ordering::Release); } } const SCK_USER_STOPPED_CODE: isize = -3817; fn is_sck_user_stop(err: &NSError) -> bool { let code = err.code(); assert!( code.abs() < i32::MAX as isize, "SCK error code out of range" ); if code != SCK_USER_STOPPED_CODE { return false; } let domain = err.domain().to_string(); assert!(!domain.is_empty(), "SCK error domain must not be empty"); domain.contains("SCStreamErrorDomain") } fn sample_timestamp_us(sample_buffer: &CMSampleBuffer) -> i64 { let pts = unsafe { sample_buffer.presentation_time_stamp() }; if pts.timescale <= 0 { return 0; } let value = pts.value as i128; let scale = pts.timescale as i128; ((value * 1_000_000) / scale).clamp(0, i64::MAX as i128) as i64 } pub struct DelegateIvars { pub inner: Weak, pub cleared: AtomicBool, } define_class!( #[unsafe(super(NSObject))] #[name = "FluxerSCKScreenSource"] #[ivars = DelegateIvars] pub struct FluxerSCKScreenSource; unsafe impl NSObjectProtocol for FluxerSCKScreenSource {} unsafe impl SCStreamDelegate for FluxerSCKScreenSource { #[unsafe(method(stream:didStopWithError:))] unsafe fn did_stop_with_error(&self, _stream: &SCStream, err: &NSError) { if self.ivars().cleared.load(Ordering::Acquire) { return; } let Some(inner_arc) = self.ivars().inner.upgrade() else { return; }; let inner: &CaptureInner = &inner_arc; inner.running.store(false, Ordering::Release); let msg = foundation::ns_error_localized_description(err); let msg = if msg.is_empty() { "stream stopped".to_string() } else { msg }; let is_clean_stop = is_sck_user_stop(err); if !is_clean_stop { if let Some(surface) = inner.failure_surface.read().as_ref().cloned() { surface.on_failure(SckCaptureFailure::StreamStoppedWithError(msg.clone())); } } let Ok(state) = inner.state.lock() else { return; }; if is_clean_stop { if let Some(tsfn) = state.lifecycle_tsfn.as_ref() { let status = tsfn.call( ("closed-clean".to_string(), msg), ThreadsafeFunctionCallMode::NonBlocking, ); note_tsfn_status(inner, status); } return; } if let Some(tsfn) = state.lifecycle_tsfn.as_ref() { let status = tsfn.call( ("error".to_string(), msg), ThreadsafeFunctionCallMode::NonBlocking, ); note_tsfn_status(inner, status); } if let Some(tsfn) = state.lifecycle_tsfn.as_ref() { let status = tsfn.call( ("closed".to_string(), String::new()), ThreadsafeFunctionCallMode::NonBlocking, ); note_tsfn_status(inner, status); } } } unsafe impl SCStreamOutput for FluxerSCKScreenSource { #[unsafe(method(stream:didOutputSampleBuffer:ofType:))] unsafe fn did_output_sample_buffer( &self, _stream: &SCStream, sample_buffer: &CMSampleBuffer, output_type: SCStreamOutputType, ) { if self.ivars().cleared.load(Ordering::Acquire) { return; } let Some(inner_arc) = self.ivars().inner.upgrade() else { return; }; if output_type == SCStreamOutputType::Screen { unsafe { handle_screen_sample(&inner_arc, sample_buffer) }; return; } if output_type == SCStreamOutputType::Audio { unsafe { handle_audio_sample(&inner_arc, sample_buffer) }; } } } ); impl FluxerSCKScreenSource { pub fn new(inner: Weak) -> Retained { let this = Self::alloc().set_ivars(DelegateIvars { inner, cleared: AtomicBool::new(false), }); unsafe { msg_send![super(this), init] } } pub fn clear_inner(&self) { self.ivars().cleared.store(true, Ordering::Release); } } unsafe fn handle_audio_sample(inner: &CaptureInner, sample_buffer: &CMSampleBuffer) { if inner.tsfn_aborted.load(Ordering::Acquire) || !inner.running.load(Ordering::Acquire) { return; } if !unsafe { sample_buffer.data_is_ready() } { return; } let num_samples = unsafe { sample_buffer.num_samples() }; if num_samples <= 0 { return; } let pts_us = sample_timestamp_us(sample_buffer); let (detected_format, detected_sample_rate_hz, detected_channels) = unsafe { detect_audio_format(sample_buffer) }; let sample_rate_hz = if detected_sample_rate_hz > 0 { detected_sample_rate_hz } else { inner.audio_sample_rate_hz.load(Ordering::Acquire) }; let channels = if detected_channels > 0 { detected_channels } else { inner.audio_channels.load(Ordering::Acquire) }; let num_samples_per_channel = num_samples as u32; let mut slot = match inner.audio_pool.try_acquire() { Some(s) => s, None => { emit_pool_exhausted(inner, sample_rate_hz, num_samples_per_channel); return; } }; if unsafe { extract_audio_bytes(sample_buffer, &mut slot) }.is_err() { return; } let payload = AudioFramePayload { sample_rate_hz, channels, num_samples_per_channel, pts_us, format: detected_format, slot, }; deliver_audio_frame(inner, payload); } fn emit_pool_exhausted(inner: &CaptureInner, sample_rate_hz: u32, num_samples: u32) { let capture_id = inner .capture_id .lock() .ok() .and_then(|guard| guard.as_ref().cloned()) .unwrap_or_default(); let Ok(state) = inner.state.lock() else { return; }; let Some(tsfn) = state.audio_diagnostic_tsfn.as_ref() else { return; }; let status = tsfn.call( ( "audioPoolExhausted".to_string(), capture_id, sample_rate_hz, num_samples, ), ThreadsafeFunctionCallMode::NonBlocking, ); note_tsfn_status(inner, status); } pub(crate) unsafe fn detect_audio_format( sample_buffer: &CMSampleBuffer, ) -> (AudioSampleFormat, u32, u32) { let Some(format_desc) = (unsafe { sample_buffer.format_description() }) else { return (AudioSampleFormat::Unknown, 0, 0); }; let asbd_ptr = unsafe { CMAudioFormatDescriptionGetStreamBasicDescription(&format_desc) }; if asbd_ptr.is_null() { return (AudioSampleFormat::Unknown, 0, 0); } let asbd: AudioStreamBasicDescription = unsafe { *asbd_ptr }; if asbd.mFormatID != kAudioFormatLinearPCM { return (AudioSampleFormat::Unknown, 0, 0); } let format = classify_pcm_flags(asbd.mFormatFlags, asbd.mBitsPerChannel); let sample_rate_hz = asbd.mSampleRate.clamp(0.0, u32::MAX as f64) as u32; let channels = asbd.mChannelsPerFrame; (format, sample_rate_hz, channels) } fn classify_pcm_flags(flags: u32, bits_per_channel: u32) -> AudioSampleFormat { let is_float = (flags & kAudioFormatFlagIsFloat) != 0; let is_signed_int = (flags & kAudioFormatFlagIsSignedInteger) != 0; let is_non_interleaved = (flags & kAudioFormatFlagIsNonInterleaved) != 0; if is_float && bits_per_channel == 32 && is_non_interleaved { return AudioSampleFormat::F32Planar; } if is_float && bits_per_channel == 32 && !is_non_interleaved { return AudioSampleFormat::F32Interleaved; } if is_signed_int && bits_per_channel == 16 && !is_non_interleaved { return AudioSampleFormat::I16Interleaved; } AudioSampleFormat::Unknown } pub(crate) const MAX_AUDIO_BUFFERS: usize = 8; #[repr(C, align(16))] pub(crate) struct AblStorage { pub n_buffers: u32, pub _pad: u32, pub buffers: [objc2_core_audio_types::AudioBuffer; MAX_AUDIO_BUFFERS], } pub(crate) unsafe fn extract_audio_bytes( sample_buffer: &CMSampleBuffer, slot: &mut PooledMacAudioFrame, ) -> std::result::Result<(), ()> { assert!(slot.capacity() <= MAX_FRAME_BYTES_PER_SLOT); let mut storage = AblStorage { n_buffers: 0, _pad: 0, buffers: [objc2_core_audio_types::AudioBuffer { mNumberChannels: 0, mDataByteSize: 0, mData: ptr::null_mut(), }; MAX_AUDIO_BUFFERS], }; let storage_size = core::mem::size_of::(); let mut block_buffer_out: *mut CMBlockBuffer = ptr::null_mut(); let status = unsafe { sample_buffer.audio_buffer_list_with_retained_block_buffer( ptr::null_mut(), &mut storage as *mut AblStorage as *mut AudioBufferList, storage_size, None, None, 0, &mut block_buffer_out, ) }; if status != 0 { unsafe { release_block_buffer(block_buffer_out) }; return Err(()); } assert!(!block_buffer_out.is_null()); let n_buffers = storage.n_buffers as usize; if n_buffers == 0 || n_buffers > MAX_AUDIO_BUFFERS { unsafe { release_block_buffer(block_buffer_out) }; return Err(()); } for i in 0..n_buffers { let ab = storage.buffers[i]; let len = ab.mDataByteSize as usize; if len == 0 || ab.mData.is_null() { continue; } let bytes = unsafe { core::slice::from_raw_parts(ab.mData as *const u8, len) }; if slot.append(bytes).is_err() { unsafe { release_block_buffer(block_buffer_out) }; return Err(()); } } unsafe { release_block_buffer(block_buffer_out) }; Ok(()) } unsafe fn release_block_buffer(block_buffer: *mut CMBlockBuffer) { if block_buffer.is_null() { return; } unsafe { CFRelease(block_buffer as *const core::ffi::c_void); } } #[link(name = "CoreFoundation", kind = "framework")] unsafe extern "C" { fn CFRelease(cf: *const core::ffi::c_void); } fn build_audio_callback_args( env: napi::Env, input: AudioFrameTsfnInput, ) -> Result { let (sample_rate_hz, channels, num_samples_per_channel, pts_us, format, slot) = input; assert!(sample_rate_hz > 0); assert!(channels > 0); let (ptr, len, slot) = slot.into_external_parts(); assert!(!ptr.is_null()); if len == 0 { drop(slot); let empty: Vec = Vec::new(); return Ok(( sample_rate_hz, channels, num_samples_per_channel, pts_us, format.code(), Buffer::from(empty), )); } let slice = unsafe { BufferSlice::from_external(&env, ptr, len, slot, |_env, slot| { drop(slot); }) }?; let buffer = slice.into_buffer(&env)?; Ok(( sample_rate_hz, channels, num_samples_per_channel, pts_us, format.code(), buffer, )) } fn deliver_audio_frame(inner: &CaptureInner, payload: AudioFramePayload) { let guard = inner.audio_frame_tsfn.read(); let Some(tsfn) = guard.as_ref() else { return; }; let status = tsfn.call( payload.into_input(), ThreadsafeFunctionCallMode::NonBlocking, ); note_tsfn_status(inner, status); } unsafe fn handle_screen_sample(inner: &CaptureInner, sample_buffer: &CMSampleBuffer) { if inner.tsfn_aborted.load(Ordering::Acquire) || !inner.running.load(Ordering::Acquire) { return; } if !unsafe { sample_buffer.data_is_ready() } { return; } let Some(image_buffer) = (unsafe { sample_buffer.image_buffer() }) else { return; }; let pixel_buffer: &CVPixelBuffer = &image_buffer; let format_type = CVPixelBufferGetPixelFormatType(pixel_buffer); if format_type == PIXEL_FORMAT_420V || format_type == PIXEL_FORMAT_420F { handle_nv12_screen_sample(inner, sample_buffer, pixel_buffer); } else if format_type == kCVPixelFormatType_32BGRA { handle_bgra_screen_sample(inner, sample_buffer, pixel_buffer); } } fn handle_nv12_screen_sample( inner: &CaptureInner, sample_buffer: &CMSampleBuffer, pixel_buffer: &CVPixelBuffer, ) { let width = CVPixelBufferGetWidth(pixel_buffer) as u32; let height = CVPixelBufferGetHeight(pixel_buffer) as u32; if width == 0 || height == 0 || height % 2 != 0 { return; } let plane_count = CVPixelBufferGetPlaneCount(pixel_buffer); if plane_count < 2 { return; } submit_encoder_ring_frame(inner, pixel_buffer, sample_timestamp_us(sample_buffer)); if let Some(sink) = inner.native_frame_sink.read().as_ref().cloned() { let outcome = try_enqueue_native_cv_pixel_buffer(&sink, pixel_buffer, width, height, sample_buffer); record_frame_sink_outcome(inner, outcome); return; } if let Some(sink) = bus_sink_for(inner).as_deref() { let outcome = try_enqueue_bus_cv_pixel_buffer(sink, pixel_buffer, width, height, sample_buffer); record_frame_sink_outcome(inner, outcome); return; } note_media_frame_without_sink(inner); } fn handle_bgra_screen_sample( inner: &CaptureInner, sample_buffer: &CMSampleBuffer, pixel_buffer: &CVPixelBuffer, ) { let width = CVPixelBufferGetWidth(pixel_buffer) as u32; let height = CVPixelBufferGetHeight(pixel_buffer) as u32; if width == 0 || height == 0 { return; } submit_encoder_ring_frame(inner, pixel_buffer, sample_timestamp_us(sample_buffer)); if let Some(sink) = inner.native_frame_sink.read().as_ref().cloned() { let outcome = try_enqueue_native_cv_pixel_buffer(&sink, pixel_buffer, width, height, sample_buffer); record_frame_sink_outcome(inner, outcome); return; } if let Some(sink) = bus_sink_for(inner).as_deref() { let outcome = try_enqueue_bus_cv_pixel_buffer(sink, pixel_buffer, width, height, sample_buffer); record_frame_sink_outcome(inner, outcome); return; } note_media_frame_without_sink(inner); } fn submit_encoder_ring_frame( inner: &CaptureInner, pixel_buffer: &CVPixelBuffer, capture_pts_us: i64, ) { if !inner.encoder_attach_requested.load(Ordering::Acquire) { return; } let attachment = match inner.encoder_attachment.read().as_ref().cloned() { Some(a) => a, None => return, }; if !attachment.is_attached() { return; } let before = attachment.stats().ring_full_events; if !try_submit_zero_copy(&attachment, pixel_buffer, capture_pts_us) { let source_ptr = pixel_buffer as *const CVPixelBuffer as *mut c_void; let _ = attachment.submit_with_blit(source_ptr, capture_pts_us); } let after = attachment.stats().ring_full_events; if after > before && !inner.encoder_ring_full_emitted.swap(true, Ordering::AcqRel) { emit_encoder_ring_full(inner); } } fn try_submit_zero_copy( attachment: &EncoderAttachment, pixel_buffer: &CVPixelBuffer, capture_pts_us: i64, ) -> bool { assert!(capture_pts_us >= 0, "capture pts clamped non-negative"); let format_type = CVPixelBufferGetPixelFormatType(pixel_buffer); if format_type != PIXEL_FORMAT_420V && format_type != PIXEL_FORMAT_420F { return false; } let width = CVPixelBufferGetWidth(pixel_buffer) as u32; let height = CVPixelBufferGetHeight(pixel_buffer) as u32; if width != attachment.width() { return false; } if height != attachment.height() { return false; } assert!(width > 0, "gated width positive"); assert!(height > 0, "gated height positive"); let lock_flags = CVPixelBufferLockFlags(0); let lock_status = unsafe { CVPixelBufferLockBaseAddress(pixel_buffer, lock_flags) }; if lock_status != 0 { return false; } let surface = CVPixelBufferGetIOSurface(Some(pixel_buffer)); let _ = unsafe { CVPixelBufferUnlockBaseAddress(pixel_buffer, lock_flags) }; let Some(surface) = surface else { return false; }; let surface_raw = CFRetained::into_raw(surface).cast::(); unsafe { attachment.submit_external_surface(surface_raw, capture_pts_us) }.is_ok() } fn emit_encoder_ring_full(inner: &CaptureInner) { let capture_id = inner .capture_id .lock() .ok() .and_then(|guard| guard.as_ref().cloned()) .unwrap_or_default(); let Ok(state) = inner.state.lock() else { return; }; let Some(tsfn) = state.encoder_diagnostic_tsfn.as_ref() else { return; }; let status = tsfn.call( ("encoderRingFull".to_string(), capture_id), ThreadsafeFunctionCallMode::NonBlocking, ); note_tsfn_status(inner, status); } fn record_frame_sink_outcome(inner: &CaptureInner, outcome: EnqueueOutcome) { match outcome { EnqueueOutcome::Accepted => { inner.frame_sink_accepted.fetch_add(1, Ordering::AcqRel); } EnqueueOutcome::Coalesced => { inner.frame_sink_coalesced.fetch_add(1, Ordering::AcqRel); emit_frame_sink_backpressure_once( inner, "macOS CVPixelBuffer frame coalesced by native frame sink", ); } EnqueueOutcome::Rejected => { inner.frame_sink_rejected.fetch_add(1, Ordering::AcqRel); emit_frame_sink_backpressure_once( inner, "macOS CVPixelBuffer frame rejected by native frame sink", ); } } } fn emit_frame_sink_backpressure_once(inner: &CaptureInner, message: &'static str) { if inner .frame_sink_backpressure_emitted .swap(true, Ordering::AcqRel) { return; } emit_lifecycle_diagnostic(inner, message); } fn note_media_frame_without_sink(inner: &CaptureInner) { inner .media_frames_dropped_without_sink .fetch_add(1, Ordering::AcqRel); if inner .frame_sink_missing_emitted .swap(true, Ordering::AcqRel) { return; } emit_lifecycle_diagnostic( inner, "macOS screen frame dropped because no native frame sink is registered", ); } fn bus_sink_for(inner: &CaptureInner) -> Option> { if let Some(sink) = inner.bus_sink.read().as_ref().cloned() { return Some(sink); } let capture_id = inner .capture_id .lock() .ok() .and_then(|guard| guard.as_ref().cloned())?; let sink = frame_bus::get_sink(&capture_id)?; *inner.bus_sink.write() = Some(sink.clone()); Some(sink) } fn retain_native_frame_sink_handle( value: Unknown<'_>, ) -> Result> { if value.get_type()? != ValueType::External { return Err(napi::Error::new( Status::InvalidArg, "ScreenCapture.setFrameSinkHandle expects a native external frame sink handle", )); } let raw_value = value.value(); let mut data: *mut c_void = ptr::null_mut(); let status = unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) }; if status != napi::sys::Status::napi_ok || data.is_null() { return Err(napi::Error::new( Status::InvalidArg, "ScreenCapture.setFrameSinkHandle received an empty native external frame sink handle", )); } let handle = unsafe { data.cast::().as_ref() } .and_then(NativeScreenFrameSinkHandle::retain_ref) .ok_or_else(|| { napi::Error::new( Status::InvalidArg, "ScreenCapture.setFrameSinkHandle received an invalid native frame sink handle", ) })?; Ok(Arc::new(handle)) } fn try_enqueue_native_cv_pixel_buffer( sink: &NativeScreenFrameSinkHandleRef, pixel_buffer: &CVPixelBuffer, width: u32, height: u32, sample_buffer: &CMSampleBuffer, ) -> EnqueueOutcome { if !sink.supports_mac_cv_pixel_buffer() { return EnqueueOutcome::Rejected; } let retained = unsafe { CVPixelBufferRetain(pixel_buffer as *const CVPixelBuffer as *mut c_void) }; if retained.is_null() { return EnqueueOutcome::Rejected; } let pixel_format = CVPixelBufferGetPixelFormatType(pixel_buffer); unsafe { sink.enqueue_mac_cv_pixel_buffer( retained, width, height, pixel_format, sample_timestamp_us(sample_buffer), ) } } fn try_enqueue_bus_cv_pixel_buffer( sink: &dyn frame_bus::ScreenFrameSink, pixel_buffer: &CVPixelBuffer, width: u32, height: u32, sample_buffer: &CMSampleBuffer, ) -> EnqueueOutcome { let retained = unsafe { CVPixelBufferRetain(pixel_buffer as *const CVPixelBuffer as *mut c_void) }; if retained.is_null() { return EnqueueOutcome::Rejected; } let pixel_format = CVPixelBufferGetPixelFormatType(pixel_buffer); let bus_frame = unsafe { MacCvPixelBufferFrame::from_retained( retained, width, height, pixel_format, sample_timestamp_us(sample_buffer), ) }; sink.enqueue(BusScreenFrame::MacCvPixelBuffer(bus_frame)) } #[napi(js_name = "ScreenCapture")] pub struct ScreenCapture { inner: Arc, } #[napi(object, js_name = "ScreenCaptureStartResult")] pub struct ScreenCaptureStartResult { pub width: u32, pub height: u32, pub frame_rate: u32, pub pixel_format: String, } #[napi(object, js_name = "FrameSinkDiagnostics")] pub struct FrameSinkDiagnostics { pub accepted: f64, pub coalesced: f64, pub rejected: f64, #[napi(js_name = "mediaFramesDroppedWithoutSink")] pub media_frames_dropped_without_sink: f64, } #[napi(object, js_name = "ScreenCaptureRect")] pub struct ScreenCaptureRect { pub x: f64, pub y: f64, pub width: f64, pub height: f64, } #[napi(object, js_name = "ScreenCaptureStartOptions")] pub struct ScreenCaptureStartOptions { #[napi(js_name = "showCursorClicks")] pub show_cursor_clicks: Option, #[napi(js_name = "captureRect")] pub capture_rect: Option, #[napi(js_name = "colorRange")] pub color_range: Option, #[napi(js_name = "colorSpace")] pub color_space: Option, #[napi(js_name = "maxOutputWidth")] pub max_output_width: Option, #[napi(js_name = "maxOutputHeight")] pub max_output_height: Option, } #[derive(Clone, Copy, Debug)] struct NormalizedStartOptions { shows_cursor: bool, source_rect: Option, pixel_format: SckPixelFormat, color_space: SckColorSpace, max_output_width: u32, max_output_height: u32, } #[napi] impl ScreenCapture { #[napi(constructor)] pub fn new() -> Self { let audio_pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT) .unwrap_or_else(|_| { unreachable!("MAC_AUDIO_POOL_CAP and MAX_FRAME_BYTES_PER_SLOT must validate") }); Self { inner: Arc::new(CaptureInner { state: Mutex::new(CaptureState { lifecycle_tsfn: None, audio_diagnostic_tsfn: None, encoder_diagnostic_tsfn: None, delegate: None, stream: None, sample_queue: None, }), audio_frame_tsfn: parking_lot::RwLock::new(None), running: AtomicBool::new(false), tsfn_aborted: AtomicBool::new(false), capture_id: Mutex::new(None), bus_sink: parking_lot::RwLock::new(None), native_frame_sink: parking_lot::RwLock::new(None), failure_surface: parking_lot::RwLock::new(None), captures_audio: AtomicBool::new(false), audio_sample_rate_hz: AtomicU32::new(AUDIO_SAMPLE_RATE_DEFAULT_HZ), audio_channels: AtomicU32::new(AUDIO_CHANNEL_COUNT_DEFAULT), audio_pool, encoder_attachment: parking_lot::RwLock::new(None), encoder_attach_requested: AtomicBool::new(false), encoder_ring_full_emitted: AtomicBool::new(false), frame_sink_accepted: AtomicU64::new(0), frame_sink_coalesced: AtomicU64::new(0), frame_sink_rejected: AtomicU64::new(0), media_frames_dropped_without_sink: AtomicU64::new(0), frame_sink_backpressure_emitted: AtomicBool::new(false), frame_sink_missing_emitted: AtomicBool::new(false), }), } } #[napi] pub fn set_lifecycle_callback(&self, callback: Function<(String, String), ()>) -> Result<()> { let tsfn: LifecycleTsfn = callback .build_threadsafe_function::<(String, String)>() .max_queue_size::<8>() .build_callback(|ctx| Ok(ctx.value))?; let mut state = lock_state(&self.inner)?; state.lifecycle_tsfn = Some(tsfn); self.inner.tsfn_aborted.store(false, Ordering::Release); Ok(()) } #[napi(js_name = "setFrameSinkHandle")] pub fn set_frame_sink_handle(&self, frame_sink_handle: Unknown<'_>) -> Result<()> { let sink = retain_native_frame_sink_handle(frame_sink_handle)?; *self.inner.native_frame_sink.write() = Some(sink); Ok(()) } #[napi(js_name = "setAudioFrameCallback")] pub fn set_audio_frame_callback( &self, callback: Function, ) -> Result<()> { let tsfn: AudioFrameTsfn = callback .build_threadsafe_function::() .max_queue_size::<16>() .build_callback(|ctx| build_audio_callback_args(ctx.env, ctx.value))?; *self.inner.audio_frame_tsfn.write() = Some(tsfn); Ok(()) } #[napi(js_name = "setAudioDiagnosticCallback")] pub fn set_audio_diagnostic_callback( &self, callback: Function, ) -> Result<()> { let tsfn: AudioDiagnosticTsfn = callback .build_threadsafe_function::() .max_queue_size::<8>() .build_callback(|ctx| Ok(ctx.value))?; let mut state = lock_state(&self.inner)?; state.audio_diagnostic_tsfn = Some(tsfn); Ok(()) } #[napi(js_name = "setEncoderDiagnosticCallback")] pub fn set_encoder_diagnostic_callback( &self, callback: Function, ) -> Result<()> { let tsfn: EncoderDiagnosticTsfn = callback .build_threadsafe_function::() .max_queue_size::<8>() .build_callback(|ctx| Ok(ctx.value))?; let mut state = lock_state(&self.inner)?; state.encoder_diagnostic_tsfn = Some(tsfn); Ok(()) } #[napi(js_name = "attachEncoder")] pub fn attach_encoder(&self, width: u32, height: u32, frame_rate: Option) -> Result<()> { if width == 0 || height == 0 { return Err(napi::Error::new( Status::InvalidArg, "ScreenCapture.attachEncoder requires positive dimensions", )); } let frame_rate = EncoderFrameRate::from_fps(frame_rate.unwrap_or(30)); let attachment = EncoderAttachment::try_new_with_frame_rate(width, height, frame_rate) .map_err(|e| { napi::Error::new(Status::GenericFailure, format!("attachEncoder failed: {e}")) })?; *self.inner.encoder_attachment.write() = Some(attachment); self.inner .encoder_attach_requested .store(true, Ordering::Release); self.inner .encoder_ring_full_emitted .store(false, Ordering::Release); Ok(()) } #[napi(js_name = "detachEncoder")] pub fn detach_encoder(&self) -> Result<()> { if let Some(attach) = self.inner.encoder_attachment.write().take() { attach.detach(); } self.inner .encoder_attach_requested .store(false, Ordering::Release); Ok(()) } #[napi(js_name = "isEncoderAttached")] pub fn is_encoder_attached(&self) -> bool { self.inner .encoder_attachment .read() .as_ref() .map(|a| a.is_attached()) .unwrap_or(false) } #[napi(js_name = "encoderRingFullCount")] pub fn encoder_ring_full_count(&self) -> u32 { self.inner .encoder_attachment .read() .as_ref() .map(|a| a.stats().ring_full_events.min(u32::MAX as u64) as u32) .unwrap_or(0) } #[napi(js_name = "getFrameSinkDiagnostics")] pub fn get_frame_sink_diagnostics(&self) -> FrameSinkDiagnostics { FrameSinkDiagnostics { accepted: self.inner.frame_sink_accepted.load(Ordering::Acquire) as f64, coalesced: self.inner.frame_sink_coalesced.load(Ordering::Acquire) as f64, rejected: self.inner.frame_sink_rejected.load(Ordering::Acquire) as f64, media_frames_dropped_without_sink: self .inner .media_frames_dropped_without_sink .load(Ordering::Acquire) as f64, } } #[napi(js_name = "enableAudioCapture")] pub fn enable_audio_capture( &self, captures_audio: bool, sample_rate_hz: u32, channels: u32, ) -> Result<()> { if !(8_000..=192_000).contains(&sample_rate_hz) { return Err(napi::Error::new( Status::InvalidArg, "ScreenCapture.enableAudioCapture sampleRateHz out of [8000..=192000]", )); } if !(1..=8).contains(&channels) { return Err(napi::Error::new( Status::InvalidArg, "ScreenCapture.enableAudioCapture channels out of [1..=8]", )); } self.inner .captures_audio .store(captures_audio, Ordering::Release); self.inner .audio_sample_rate_hz .store(sample_rate_hz, Ordering::Release); self.inner.audio_channels.store(channels, Ordering::Release); Ok(()) } pub fn install_failure_surface(&self, surface: Arc) { *self.inner.failure_surface.write() = Some(surface); } pub fn clear_failure_surface(&self) { self.inner.failure_surface.write().take(); } #[napi] pub async fn start( &self, source_id: String, source_kind: String, width: u32, height: u32, frame_rate: u32, capture_id: Option, capture_options: Option, ) -> Result { let start_options = normalize_start_options(capture_options)?; let normalized_capture_id = capture_id .map(|raw| raw.trim().to_string()) .filter(|trimmed| !trimmed.is_empty()); *self .inner .capture_id .lock() .map_err(|_| generic_error("ScreenCapture capture_id lock poisoned"))? = normalized_capture_id.clone(); *self.inner.bus_sink.write() = if self.inner.native_frame_sink.read().is_some() { None } else { normalized_capture_id .as_deref() .and_then(frame_bus::get_sink) }; let source_id_num: u32 = source_id.parse().map_err(|_| { napi::Error::new( Status::InvalidArg, "ScreenCapture.start sourceId must be a u32 string", ) })?; if source_id_num == 0 { return Err(napi::Error::new( Status::InvalidArg, "ScreenCapture.start sourceId must be > 0", )); } let source_id = source_id_num; { let detected = os_version::current_macos_version(); let SupportClassification { supported, reason, .. } = classify_support(detected); if !supported { return Err(napi::Error::new(Status::GenericFailure, reason)); } } { let state = lock_state(&self.inner)?; if state.stream.is_some() || state.delegate.is_some() || self.inner.running.load(Ordering::Acquire) { return Err(napi::Error::new( Status::GenericFailure, "ScreenCapture is already running", )); } } let content = get_shareable_content(DEFAULT_TIMEOUT_NS) .map_err(|_| generic_error("Failed to fetch SCShareableContent"))?; let (filter, dims) = build_filter_for_source(&content, source_id, &source_kind, width, height)?; let point_pixel_scale = sck::filter_point_pixel_scale_if_available(&filter); let content_rect = sck::filter_content_rect_if_available(&filter); let (source_width, source_height) = resolve_source_pixels( width, height, start_options.source_rect, content_rect, point_pixel_scale, dims, ); let (final_width, final_height) = cap_output_dims( source_width, source_height, start_options.max_output_width, start_options.max_output_height, ); assert!(final_width >= crate::config::OUTPUT_DIMENSION_MIN); assert!(final_height >= crate::config::OUTPUT_DIMENSION_MIN); let effective_fps = if frame_rate == 0 { 30 } else { frame_rate.min(240) }; let cfg: Retained = unsafe { SCStreamConfiguration::new() }; sck::cfg_set_width(&cfg, final_width as usize); sck::cfg_set_height(&cfg, final_height as usize); sck::cfg_set_scales_to_fit_if_available(&cfg, true); sck::cfg_set_shows_cursor(&cfg, start_options.shows_cursor); if let Some(source_rect) = start_options.source_rect { if !sck::cfg_set_source_rect_if_available(&cfg, source_rect) { emit_lifecycle_diagnostic( &self.inner, "captureRect requested but this ScreenCaptureKit runtime lacks setSourceRect", ); } } let captures_audio = self.inner.captures_audio.load(Ordering::Acquire); let audio_sample_rate_hz = self.inner.audio_sample_rate_hz.load(Ordering::Acquire); let audio_channels = self.inner.audio_channels.load(Ordering::Acquire); let capture_cfg = build_capture_config( effective_fps, captures_audio, audio_sample_rate_hz, audio_channels, start_options.pixel_format, start_options.color_space, ); sck::apply_capture_config(&cfg, &capture_cfg); let stream_name = format!("Fluxer ScreenCapture ({}:{})", source_kind, source_id); let nsname = NSString::from_str(&stream_name); sck::cfg_set_stream_name_if_available(&cfg, &nsname); let delegate = FluxerSCKScreenSource::new(Arc::downgrade(&self.inner)); let stream_alloc = SCStream::alloc(); let stream: Retained = unsafe { SCStream::initWithFilter_configuration_delegate( stream_alloc, &filter, &cfg, Some(ProtocolObject::from_ref(&*delegate)), ) }; let sample_queue = build_scstream_sample_queue(&source_kind, source_id); if sck::sc_stream_add_stream_output( &stream, ProtocolObject::from_ref(&*delegate), SCStreamOutputType::Screen, Some(&sample_queue), ) .is_err() { delegate.clear_inner(); return Err(generic_error("Failed to add screen stream output")); } if captures_audio && sck::sc_stream_add_stream_output( &stream, ProtocolObject::from_ref(&*delegate), SCStreamOutputType::Audio, Some(&sample_queue), ) .is_err() { delegate.clear_inner(); return Err(generic_error("Failed to add audio stream output")); } { let mut state = lock_state(&self.inner)?; state.delegate = Some(delegate.clone()); state.stream = Some(stream.clone()); state.sample_queue = Some(sample_queue); } self.inner.running.store(true, Ordering::Release); if await_start(&stream, DEFAULT_TIMEOUT_NS).is_err() { self.inner.running.store(false, Ordering::Release); if let Some(surface) = self.inner.failure_surface.read().as_ref().cloned() { surface.on_failure(SckCaptureFailure::StreamStartFailed( "SCStream startCapture failed".to_string(), )); } let mut state = lock_state(&self.inner)?; if let Some(d) = state.delegate.take() { d.clear_inner(); } state.stream = None; return Err(generic_error("SCStream startCapture failed")); } Ok(ScreenCaptureStartResult { width: final_width, height: final_height, frame_rate: effective_fps, pixel_format: "nv12".to_string(), }) } #[napi] pub async fn stop(&self) -> Result<()> { self.inner.running.store(false, Ordering::Release); if let Ok(mut guard) = self.inner.capture_id.lock() { guard.take(); } self.inner.bus_sink.write().take(); self.inner.native_frame_sink.write().take(); self.inner.failure_surface.write().take(); if let Some(attach) = self.inner.encoder_attachment.write().take() { attach.detach(); } self.inner .encoder_attach_requested .store(false, Ordering::Release); let (delegate, stream, queue) = { let mut state = lock_state(&self.inner)?; let delegate = state.delegate.take(); let stream = state.stream.take(); let queue = state.sample_queue.take(); (delegate, stream, queue) }; if let Some(d) = delegate.as_ref() { d.clear_inner(); } if let Some(s) = stream.as_ref() { let _ = await_stop(s, DEFAULT_TIMEOUT_NS); } drop(stream); drop(delegate); drop(queue); Ok(()) } } impl Drop for ScreenCapture { fn drop(&mut self) { self.inner.running.store(false, Ordering::Release); self.inner.bus_sink.write().take(); self.inner.native_frame_sink.write().take(); self.inner.failure_surface.write().take(); if let Some(attach) = self.inner.encoder_attachment.write().take() { attach.detach(); } self.inner .encoder_attach_requested .store(false, Ordering::Release); let (delegate, stream, _queue) = match self.inner.state.lock() { Ok(mut s) => (s.delegate.take(), s.stream.take(), s.sample_queue.take()), Err(_) => (None, None, None), }; if let Some(d) = delegate.as_ref() { d.clear_inner(); } if let Some(s) = stream.as_ref() { let _ = await_stop(s, DEFAULT_TIMEOUT_NS); } } } fn build_scstream_sample_queue( source_kind: &str, source_id: u32, ) -> dispatch2::DispatchRetained { assert!(!source_kind.is_empty(), "source kind non-empty"); assert!(source_id > 0, "source id positive"); let label = format!("com.fluxer.scstream.{}-{}", source_kind, source_id); let queue_attr = dispatch2::DispatchQueueAttr::with_qos_class( dispatch2::DispatchQueueAttr::SERIAL, dispatch2::DispatchQoS::UserInteractive, 0, ); dispatch2::DispatchQueue::new(label.as_str(), Some(&queue_attr)) } fn build_capture_config( effective_fps: u32, captures_audio: bool, audio_sample_rate_hz: u32, audio_channels: u32, pixel_format: SckPixelFormat, color_space: SckColorSpace, ) -> SckCaptureConfig { assert!(effective_fps >= crate::config::FPS_MIN); assert!(audio_channels >= 1); let clamped_fps = effective_fps.clamp(crate::config::FPS_MIN, crate::config::FPS_MAX); SckCaptureConfig::builder() .target_fps(clamped_fps) .queue_depth(crate::config::QUEUE_DEPTH_DEFAULT) .pixel_format(pixel_format) .color_space(color_space) .captures_audio(captures_audio) .audio_sample_rate_hz(audio_sample_rate_hz) .audio_channels(audio_channels) .build() .unwrap_or_default() } fn normalize_start_options( options: Option, ) -> Result { let Some(options) = options else { return Ok(NormalizedStartOptions::default()); }; let source_rect = normalize_capture_rect(options.capture_rect)?; let pixel_format = normalize_color_range(options.color_range.as_deref())?; let color_space = normalize_color_space(options.color_space.as_deref())?; let max_output_width = normalize_max_output_dimension( options.max_output_width, crate::config::MAX_OUTPUT_WIDTH_DEFAULT, ); let max_output_height = normalize_max_output_dimension( options.max_output_height, crate::config::MAX_OUTPUT_HEIGHT_DEFAULT, ); Ok(NormalizedStartOptions { shows_cursor: options.show_cursor_clicks.unwrap_or(false), source_rect, pixel_format, color_space, max_output_width, max_output_height, }) } fn normalize_max_output_dimension(requested: Option, fallback: u32) -> u32 { assert!(fallback >= crate::config::OUTPUT_DIMENSION_MIN); let resolved = requested .filter(|v| *v >= crate::config::OUTPUT_DIMENSION_MIN) .unwrap_or(fallback); let even = resolved & !1; assert!(even >= crate::config::OUTPUT_DIMENSION_MIN); even } fn normalize_capture_rect(rect: Option) -> Result> { let Some(rect) = rect else { return Ok(None); }; if !rect.x.is_finite() || !rect.y.is_finite() { return Err(invalid_arg( "ScreenCapture.start captureRect origin must be finite", )); } if !rect.width.is_finite() || !rect.height.is_finite() { return Err(invalid_arg( "ScreenCapture.start captureRect size must be finite", )); } if rect.x < 0.0 || rect.y < 0.0 || rect.width <= 0.0 || rect.height <= 0.0 { return Err(invalid_arg( "ScreenCapture.start captureRect requires non-negative x/y and positive width/height", )); } if rect.width > u32::MAX as f64 || rect.height > u32::MAX as f64 { return Err(invalid_arg( "ScreenCapture.start captureRect width/height exceed u32", )); } Ok(Some(CGRect::new( CGPoint::new(rect.x, rect.y), CGSize::new(rect.width, rect.height), ))) } fn normalize_color_range(value: Option<&str>) -> Result { match value { None | Some("limited") => Ok(SckPixelFormat::Nv12VideoRange), Some("full") => Ok(SckPixelFormat::Nv12FullRange), Some(_) => Err(invalid_arg( "ScreenCapture.start colorRange must be 'limited' or 'full'", )), } } fn normalize_color_space(value: Option<&str>) -> Result { match value { None | Some("srgb") | Some("rec709") => Ok(SckColorSpace::SrgbBt709), Some(_) => Err(invalid_arg( "ScreenCapture.start colorSpace must be 'srgb' or 'rec709'", )), } } impl Default for NormalizedStartOptions { fn default() -> Self { Self { shows_cursor: false, source_rect: None, pixel_format: SckPixelFormat::Nv12VideoRange, color_space: SckColorSpace::SrgbBt709, max_output_width: crate::config::MAX_OUTPUT_WIDTH_DEFAULT, max_output_height: crate::config::MAX_OUTPUT_HEIGHT_DEFAULT, } } } fn emit_lifecycle_diagnostic(inner: &CaptureInner, message: &'static str) { let Ok(state) = inner.state.lock() else { return; }; let Some(tsfn) = state.lifecycle_tsfn.as_ref() else { return; }; let status = tsfn.call( ("diagnostic".to_string(), message.to_string()), ThreadsafeFunctionCallMode::NonBlocking, ); note_tsfn_status(inner, status); } fn even_floor(value: u32) -> u32 { value & !1 } fn resolve_source_pixels( requested_width: u32, requested_height: u32, source_rect: Option, content_rect: Option, point_pixel_scale: Option, point_dims_fallback: (u32, u32), ) -> (u32, u32) { let scale = point_pixel_scale .filter(|s| s.is_finite() && *s > 0.0) .map(|s| s as f64) .unwrap_or(1.0); assert!(scale > 0.0); let min = crate::config::OUTPUT_DIMENSION_MIN; if let Some(rect) = source_rect { assert!(rect.size.width > 0.0); assert!(rect.size.height > 0.0); let w = (rect.size.width * scale).round() as u32; let h = (rect.size.height * scale).round() as u32; return (even_floor(w.max(min)), even_floor(h.max(min))); } if let Some(rect) = content_rect { assert!(rect.size.width > 0.0); assert!(rect.size.height > 0.0); let w = (rect.size.width * scale).round() as u32; let h = (rect.size.height * scale).round() as u32; return (even_floor(w.max(min)), even_floor(h.max(min))); } if requested_width > 0 && requested_height > 0 { return ( even_floor(requested_width.max(min)), even_floor(requested_height.max(min)), ); } ( even_floor(point_dims_fallback.0.max(min)), even_floor(point_dims_fallback.1.max(min)), ) } fn cap_output_dims(src_w: u32, src_h: u32, max_w: u32, max_h: u32) -> (u32, u32) { let min = crate::config::OUTPUT_DIMENSION_MIN; assert!(src_w >= min); assert!(src_h >= min); let max_w = even_floor(max_w).max(min); let max_h = even_floor(max_h).max(min); let scale = (max_w as f64 / src_w as f64) .min(max_h as f64 / src_h as f64) .min(1.0); assert!(scale > 0.0); assert!(scale <= 1.0); let out_w = even_floor(((src_w as f64) * scale).round() as u32).clamp(min, max_w); let out_h = even_floor(((src_h as f64) * scale).round() as u32).clamp(min, max_h); assert!(out_w <= max_w); assert!(out_h <= max_h); (out_w, out_h) } fn build_filter_for_source( content: &SCShareableContent, source_id: u32, source_kind: &str, requested_width: u32, requested_height: u32, ) -> Result<(Retained, (u32, u32))> { match source_kind { "screen" => { let displays: Retained> = unsafe { content.displays() }; let count = displays.count(); let mut chosen: Option> = None; for i in 0..count { let d = displays.objectAtIndex(i); if sck::sc_display_display_id(&d) == source_id { chosen = Some(d); break; } } let display = chosen.ok_or_else(|| generic_error("No SCDisplay matches requested sourceId"))?; let dims = ( if requested_width > 0 { requested_width } else { sck::sc_display_width(&display).max(0) as u32 }, if requested_height > 0 { requested_height } else { sck::sc_display_height(&display).max(0) as u32 }, ); let empty: Retained> = NSArray::new(); let filter_alloc = SCContentFilter::alloc(); let filter: Retained = unsafe { SCContentFilter::initWithDisplay_excludingWindows(filter_alloc, &display, &empty) }; Ok((filter, dims)) } "window" => { let windows: Retained> = unsafe { content.windows() }; let count = windows.count(); let mut chosen: Option> = None; for i in 0..count { let w = windows.objectAtIndex(i); if sck::sc_window_window_id(&w) == source_id { chosen = Some(w); break; } } let window = chosen.ok_or_else(|| generic_error("No SCWindow matches requested sourceId"))?; let frame = sck::sc_window_frame(&window); let dims = ( if requested_width > 0 { requested_width } else { frame.size.width.max(0.0) as u32 }, if requested_height > 0 { requested_height } else { frame.size.height.max(0.0) as u32 }, ); let filter_alloc = SCContentFilter::alloc(); let filter: Retained = unsafe { SCContentFilter::initWithDesktopIndependentWindow(filter_alloc, &window) }; Ok((filter, dims)) } other => Err(napi::Error::new( Status::InvalidArg, format!("Unknown sourceKind: {other}"), )), } } #[napi(object, js_name = "MacScreenCaptureSource")] pub struct MacScreenCaptureSource { pub kind: String, pub id: String, pub name: String, pub width: u32, pub height: u32, pub app_name: Option, pub bundle_id: Option, pub target_pid: Option, } #[napi(js_name = "listSources")] pub async fn list_sources() -> Result> { let content = match get_shareable_content(DEFAULT_TIMEOUT_NS) { Ok(c) => c, Err(_) => return Ok(Vec::new()), }; let mut out: Vec = Vec::new(); let displays: Retained> = unsafe { content.displays() }; let dcount = displays.count(); for i in 0..dcount { let d = displays.objectAtIndex(i); let id = sck::sc_display_display_id(&d); let name = format!("Display {}", i + 1); out.push(MacScreenCaptureSource { kind: "screen".to_string(), id: id.to_string(), name, width: sck::sc_display_width(&d).max(0) as u32, height: sck::sc_display_height(&d).max(0) as u32, app_name: None, bundle_id: None, target_pid: None, }); } let windows: Retained> = unsafe { content.windows() }; let wcount = windows.count(); for i in 0..wcount { let w = windows.objectAtIndex(i); if !sck::sc_window_is_on_screen(&w) { continue; } let id = sck::sc_window_window_id(&w); let title = sck::sc_window_title(&w) .map(|s| s.to_string()) .unwrap_or_default(); let frame = sck::sc_window_frame(&w); let (app_name, bundle_id, target_pid) = match sck::sc_window_owning_application(&w) { Some(app) => ( Some(sck::sc_running_application_name(&app).to_string()), Some(sck::sc_running_application_bundle_identifier(&app).to_string()), Some(sck::sc_running_application_process_id(&app)).filter(|pid| *pid > 0), ), None => (None, None, None), }; let display_name = if title.is_empty() { app_name.clone().unwrap_or_else(|| format!("Window {id}")) } else if let Some(ref app) = app_name { format!("{app} — {title}") } else { title }; out.push(MacScreenCaptureSource { kind: "window".to_string(), id: id.to_string(), name: display_name, width: frame.size.width.max(0.0) as u32, height: frame.size.height.max(0.0) as u32, app_name, bundle_id, target_pid, }); } Ok(out) } #[napi(object, js_name = "MacScreenCaptureBackendSckAvailability")] pub struct SckAvailability { pub supported: bool, pub macos_version: Option, } #[napi(object, js_name = "MacScreenCaptureBackendAvailability")] pub struct BackendAvailability { pub sck: SckAvailability, pub screen_permission: String, } #[napi(js_name = "getBackendAvailability")] pub async fn get_backend_availability() -> Result { use objc2::runtime::AnyClass; let sck_supported = AnyClass::get(c"SCStream").is_some(); let version_str = Some(foundation::operating_system_version_string()); let screen_permission = if unsafe { CGPreflightScreenCaptureAccess() } { "granted" } else { "denied" }; Ok(BackendAvailability { sck: SckAvailability { supported: sck_supported, macos_version: version_str, }, screen_permission: screen_permission.to_string(), }) } fn shareable_content_outcome(timeout_ns: u64) -> ShareableContentOutcome { match get_shareable_content(timeout_ns) { Ok(_) => ShareableContentOutcome::Delivered, Err(AsyncError::Timeout) => ShareableContentOutcome::TimedOut, Err(AsyncError::SckErr) => ShareableContentOutcome::Failed, } } #[napi(js_name = "probeScreenRecordingAccess")] pub async fn probe_screen_recording_access() -> Result { use objc2::runtime::AnyClass; let sck_available = AnyClass::get(c"SCShareableContent").is_some(); let outcome = if sck_available { shareable_content_outcome(PROBE_TIMEOUT_NS) } else { ShareableContentOutcome::Failed }; Ok(screen_recording_probe_result(sck_available, outcome).to_owned()) } #[napi(object, js_name = "MacScreenCaptureBackendInfo")] pub struct MacScreenCaptureBackendInfo { pub backend: String, pub supported: bool, pub reason: String, #[napi(js_name = "minMacosVersion")] pub min_macos_version: String, #[napi(js_name = "detectedMacosVersion")] pub detected_macos_version: Option, #[napi(js_name = "sckAvailable")] pub sck_available: bool, } #[napi(js_name = "getBackendInfo")] pub fn get_backend_info() -> MacScreenCaptureBackendInfo { let detected = os_version::current_macos_version(); let SupportClassification { supported, sck_available, reason, } = classify_support(detected); MacScreenCaptureBackendInfo { backend: "mac-screen-capture".to_owned(), supported, reason, min_macos_version: format_version(SCK_MIN_MACOS), detected_macos_version: detected.map(format_version), sck_available, } } #[cfg(test)] mod dispatch_queue_tests { use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use super::build_scstream_sample_queue; #[test] fn sample_queue_construction_does_not_trap_libdispatch() { let _queue = build_scstream_sample_queue("screen", 1); } #[test] fn sample_queue_dispatches_blocks_without_crashing() { let queue = build_scstream_sample_queue("screen", 2); let counter = Arc::new(AtomicU64::new(0)); for _ in 0..32 { let c = counter.clone(); queue.exec_async(move || { c.fetch_add(1, Ordering::Release); }); } let deadline = std::time::Instant::now() + Duration::from_secs(2); while counter.load(Ordering::Acquire) < 32 { if std::time::Instant::now() > deadline { panic!( "sample queue did not drain blocks: {}/{}", counter.load(Ordering::Acquire), 32 ); } std::thread::sleep(Duration::from_millis(10)); } } #[test] fn audio_frame_payload_into_input_keeps_slot_and_metadata() { use crate::audio_pool::MacAudioFramePool; let pool = MacAudioFramePool::new(2, 64).expect("pool"); let mut slot = pool.try_acquire().expect("slot"); let bytes = vec![0xAB_u8; 8]; slot.write(&bytes).expect("write"); let payload = super::AudioFramePayload { sample_rate_hz: 48_000, channels: 2, num_samples_per_channel: 1024, pts_us: 7_777, format: super::AudioSampleFormat::F32Planar, slot, }; let (sr, ch, ns, pts, fmt, slot) = payload.into_input(); assert_eq!(sr, 48_000); assert_eq!(ch, 2); assert_eq!(ns, 1024); assert_eq!(pts, 7_777); assert_eq!(fmt, super::AudioSampleFormat::F32Planar); assert_eq!(fmt.code(), 0); assert_eq!(slot.filled_len(), bytes.len()); assert_eq!(slot.data_slice(), &bytes[..]); drop(slot); assert_eq!(pool.stats().in_flight, 0); } #[test] fn classify_pcm_flags_detects_f32_planar() { use objc2_core_audio_types::{kAudioFormatFlagIsFloat, kAudioFormatFlagIsNonInterleaved}; let flags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved; assert_eq!( super::classify_pcm_flags(flags, 32), super::AudioSampleFormat::F32Planar ); } #[test] fn classify_pcm_flags_detects_f32_interleaved() { use objc2_core_audio_types::kAudioFormatFlagIsFloat; let flags = kAudioFormatFlagIsFloat; assert_eq!( super::classify_pcm_flags(flags, 32), super::AudioSampleFormat::F32Interleaved ); } #[test] fn classify_pcm_flags_distinguishes_planar_from_interleaved_float() { use objc2_core_audio_types::{kAudioFormatFlagIsFloat, kAudioFormatFlagIsNonInterleaved}; let planar = super::classify_pcm_flags( kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved, 32, ); let interleaved = super::classify_pcm_flags(kAudioFormatFlagIsFloat, 32); assert_eq!(planar, super::AudioSampleFormat::F32Planar); assert_eq!(interleaved, super::AudioSampleFormat::F32Interleaved); assert_ne!(planar, interleaved); assert_eq!(planar.bytes_per_sample(), interleaved.bytes_per_sample()); } #[test] fn classify_pcm_flags_detects_i16_interleaved() { use objc2_core_audio_types::kAudioFormatFlagIsSignedInteger; let flags = kAudioFormatFlagIsSignedInteger; assert_eq!( super::classify_pcm_flags(flags, 16), super::AudioSampleFormat::I16Interleaved ); } #[test] fn classify_pcm_flags_rejects_unknown() { assert_eq!( super::classify_pcm_flags(0, 24), super::AudioSampleFormat::Unknown ); } #[test] fn build_capture_config_preserves_frame_interval_when_audio_enabled() { let cfg_off = super::build_capture_config( 60, false, 48_000, 2, super::SckPixelFormat::Nv12VideoRange, super::SckColorSpace::SrgbBt709, ); let cfg_on = super::build_capture_config( 60, true, 48_000, 2, super::SckPixelFormat::Nv12VideoRange, super::SckColorSpace::SrgbBt709, ); assert!(!cfg_off.captures_audio()); assert!(cfg_on.captures_audio()); assert_eq!( cfg_off.minimum_frame_interval_ns(), cfg_on.minimum_frame_interval_ns() ); } #[test] fn build_capture_config_includes_audio_settings() { let cfg = super::build_capture_config( 30, true, 44_100, 1, super::SckPixelFormat::Nv12VideoRange, super::SckColorSpace::SrgbBt709, ); assert!(cfg.captures_audio()); assert_eq!(cfg.audio_sample_rate_hz(), 44_100); assert_eq!(cfg.audio_channels(), 1); } #[test] fn start_options_include_cursor_color_and_rect_intent() { let options = super::normalize_start_options(Some(super::ScreenCaptureStartOptions { show_cursor_clicks: Some(true), capture_rect: Some(super::ScreenCaptureRect { x: 10.0, y: 20.0, width: 300.0, height: 200.0, }), color_range: Some("full".to_string()), color_space: Some("rec709".to_string()), max_output_width: None, max_output_height: None, })) .expect("valid start options"); assert!(options.shows_cursor); assert_eq!(options.pixel_format, super::SckPixelFormat::Nv12FullRange); assert_eq!(options.color_space, super::SckColorSpace::SrgbBt709); let rect = options.source_rect.expect("capture rect"); assert_eq!(rect.origin.x, 10.0); assert_eq!(rect.origin.y, 20.0); assert_eq!(rect.size.width, 300.0); assert_eq!(rect.size.height, 200.0); } #[test] fn start_options_reject_invalid_color_range() { let err = super::normalize_start_options(Some(super::ScreenCaptureStartOptions { show_cursor_clicks: None, capture_rect: None, color_range: Some("wide".to_string()), color_space: None, max_output_width: None, max_output_height: None, })) .expect_err("invalid color range"); assert_eq!(err.status, napi::Status::InvalidArg); } #[test] fn parallel_sample_queue_construction_is_safe() { let handles: Vec<_> = (0..8) .map(|i| { std::thread::spawn(move || { let q = build_scstream_sample_queue("window", (i + 1) as u32); let done = Arc::new(AtomicU64::new(0)); let dc = done.clone(); q.exec_async(move || { dc.store(1, Ordering::Release); }); let deadline = std::time::Instant::now() + Duration::from_secs(1); while done.load(Ordering::Acquire) == 0 { if std::time::Instant::now() > deadline { panic!("parallel queue {} did not drain", i); } std::thread::sleep(Duration::from_millis(5)); } }) }) .collect(); for h in handles { h.join().expect("worker panicked"); } } } #[cfg(all(test, target_os = "macos"))] mod audio_extract_tests { use super::*; use objc2_core_audio_types::AudioBufferList; #[test] fn ablstorage_size_is_large_enough_for_audio_buffer_list() { let storage_size = core::mem::size_of::(); let abl_size = core::mem::size_of::(); assert!( storage_size > abl_size, "AblStorage must hold > 1 AudioBuffer" ); assert_eq!(core::mem::align_of::(), 16); } #[test] fn ablstorage_layout_matches_apple_abl() { let storage_size = core::mem::size_of::(); let n_buffers_offset = core::mem::offset_of!(super::AblStorage, n_buffers); let buffers_offset = core::mem::offset_of!(super::AblStorage, buffers); assert_eq!(n_buffers_offset, 0, "n_buffers must be at offset 0"); assert_eq!( buffers_offset, 8, "buffers must be at offset 8 (after 4 bytes pad)" ); assert!(storage_size >= 24, "must hold at least one AudioBuffer"); } #[test] fn extract_audio_bytes_rejects_metadata_sample_buffer_gracefully() { let pool = MacAudioFramePool::new(2, 256).expect("pool"); assert_eq!(pool.try_acquire().map(|s| s.capacity()), Some(256)); } } #[cfg(test)] mod dimension_tests { use super::*; use objc2_core_foundation::{CGPoint, CGSize}; fn rect(w: f64, h: f64) -> CGRect { CGRect { origin: CGPoint { x: 0.0, y: 0.0 }, size: CGSize { width: w, height: h, }, } } #[test] fn even_floor_clears_low_bit() { assert_eq!(even_floor(3841), 3840); assert_eq!(even_floor(3840), 3840); assert_eq!(even_floor(1), 0); } #[test] fn cap_downscales_8k_to_4k_preserving_aspect() { assert_eq!(cap_output_dims(7680, 4320, 3840, 2160), (3840, 2160)); } #[test] fn cap_is_noop_below_ceiling() { assert_eq!(cap_output_dims(1920, 1080, 3840, 2160), (1920, 1080)); } #[test] fn cap_preserves_aspect_for_5k_ultrawide() { let (w, h) = cap_output_dims(5120, 2160, 3840, 2160); assert_eq!(h, 1620); assert_eq!(w, 3840); assert!(w <= 3840 && h <= 2160); } #[test] fn cap_outputs_are_even_and_bounded() { for (sw, sh) in [(7680u32, 4320u32), (5120, 2880), (3008, 1692), (1366, 768)] { let (w, h) = cap_output_dims(sw, sh, 3840, 2160); assert_eq!(w % 2, 0); assert_eq!(h % 2, 0); assert!(w <= 3840); assert!(h <= 2160); } } #[test] fn resolve_prefers_content_rect_times_scale() { let dims = resolve_source_pixels( 7680, 4320, None, Some(rect(3840.0, 2160.0)), Some(2.0), (3840, 2160), ); assert_eq!(dims, (7680, 4320)); } #[test] fn resolve_falls_back_to_requested_without_selectors() { assert_eq!( resolve_source_pixels(7680, 4320, None, None, None, (1920, 1080)), (7680, 4320) ); } #[test] fn resolve_source_rect_takes_priority() { let dims = resolve_source_pixels( 7680, 4320, Some(rect(1280.0, 720.0)), Some(rect(3840.0, 2160.0)), Some(2.0), (3840, 2160), ); assert_eq!(dims, (2560, 1440)); } #[test] fn resolve_then_cap_fills_4k_from_8k_backing() { let (sw, sh) = resolve_source_pixels(0, 0, None, Some(rect(3840.0, 2160.0)), Some(2.0), (0, 0)); assert_eq!(cap_output_dims(sw, sh, 3840, 2160), (3840, 2160)); } } #[cfg(test)] mod external_buffer_tests { use super::*; use crate::audio_pool::MacAudioFramePool; use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; struct CountingAllocator; thread_local! { static THREAD_ALLOC_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; static THREAD_ALLOC_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; static THREAD_TRACKING: std::cell::Cell = const { std::cell::Cell::new(false) }; } fn tracking_enabled() -> bool { THREAD_TRACKING.try_with(|t| t.get()).unwrap_or(false) } fn record_alloc(layout: Layout) { let _ = THREAD_ALLOC_COUNT.try_with(|c| c.set(c.get() + 1)); let _ = THREAD_ALLOC_BYTES.try_with(|b| b.set(b.get() + layout.size() as u64)); } fn reset_counts() { let _ = THREAD_ALLOC_COUNT.try_with(|c| c.set(0)); let _ = THREAD_ALLOC_BYTES.try_with(|b| b.set(0)); } fn snapshot_counts() -> (u64, u64) { let count = THREAD_ALLOC_COUNT.try_with(|c| c.get()).unwrap_or(0); let bytes = THREAD_ALLOC_BYTES.try_with(|b| b.get()).unwrap_or(0); (count, bytes) } fn set_tracking(enabled: bool) { let _ = THREAD_TRACKING.try_with(|t| t.set(enabled)); } unsafe impl GlobalAlloc for CountingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { if tracking_enabled() { record_alloc(layout); } unsafe { System.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { unsafe { System.dealloc(ptr, layout) } } } #[global_allocator] static GLOBAL: CountingAllocator = CountingAllocator; struct FakeExternalBuffer { ptr: *mut u8, len: usize, slot: Option, } unsafe impl Send for FakeExternalBuffer {} impl FakeExternalBuffer { fn from_pooled(mut slot: PooledMacAudioFrame) -> Self { let len = slot.filled_len(); let ptr = slot.as_mut_ptr(); assert!(!ptr.is_null()); Self { ptr, len, slot: Some(slot), } } fn data(&self) -> &[u8] { assert!(!self.ptr.is_null()); unsafe { core::slice::from_raw_parts(self.ptr, self.len) } } } impl Drop for FakeExternalBuffer { fn drop(&mut self) { if let Some(slot) = self.slot.take() { drop(slot); } } } #[test] fn audio_buffer_external_pointer_round_trip() { let pool = MacAudioFramePool::new(4, 256).expect("pool"); let mut slot = pool.try_acquire().expect("slot"); let payload = [0x42_u8; 64]; slot.write(&payload).expect("write"); let fake = FakeExternalBuffer::from_pooled(slot); assert_eq!(pool.stats().in_flight, 1); assert_eq!(fake.data(), &payload[..]); drop(fake); assert_eq!(pool.stats().in_flight, 0); assert_eq!(pool.stats().released, 1); assert_eq!(pool.stats().acquired, 1); } #[test] fn multi_frame_external_buffer_lifecycle() { const FRAMES: u64 = 100; let pool = MacAudioFramePool::new(8, 128).expect("pool"); for i in 0..FRAMES { let mut slot = pool.try_acquire().expect("slot"); let byte = (i % 256) as u8; let payload = [byte; 64]; slot.write(&payload).expect("write"); let fake = FakeExternalBuffer::from_pooled(slot); assert_eq!(fake.data()[0], byte); drop(fake); } let stats = pool.stats(); assert_eq!(stats.acquired, FRAMES); assert_eq!(stats.released, FRAMES); assert_eq!(stats.in_flight, 0); assert_eq!(stats.dropped, 0); } #[test] fn pool_exhaustion_with_external_buffers_holds_diagnostic() { let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, 256).expect("pool"); let mut held: Vec = Vec::with_capacity(MAC_AUDIO_POOL_CAP); for _ in 0..MAC_AUDIO_POOL_CAP { let mut slot = pool.try_acquire().expect("within cap"); slot.write(&[0xAB_u8; 32]).expect("write"); held.push(FakeExternalBuffer::from_pooled(slot)); } assert_eq!(pool.stats().in_flight as usize, MAC_AUDIO_POOL_CAP); let next = pool.try_acquire(); assert!(next.is_none(), "17th acquire must report exhaustion"); assert_eq!(pool.stats().dropped, 1); drop(held); assert_eq!(pool.stats().in_flight, 0); } #[test] fn buffer_external_drop_fn_runs_on_pool_thread_or_napi_thread() { let pool = Arc::new(MacAudioFramePool::new(4, 128).expect("pool")); let pool_clone = Arc::clone(&pool); let mut slot = pool.try_acquire().expect("slot"); slot.write(&[0x99_u8; 32]).expect("write"); let fake = FakeExternalBuffer::from_pooled(slot); assert_eq!(pool.stats().in_flight, 1); let handle = thread::spawn(move || { let fake = fake; assert_eq!(fake.data()[0], 0x99); drop(fake); }); handle.join().expect("worker"); assert_eq!(pool_clone.stats().in_flight, 0); assert_eq!(pool_clone.stats().released, 1); } #[test] fn zero_allocation_in_steady_state_acquire_write_external_release() { let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT).expect("pool"); let payload = vec![0xCC_u8; 3840]; for _ in 0..16 { let mut slot = pool.try_acquire().expect("warmup"); slot.write(&payload).expect("warmup write"); let fake = FakeExternalBuffer::from_pooled(slot); drop(fake); } reset_counts(); set_tracking(true); for _ in 0..1000 { let mut slot = pool.try_acquire().expect("steady-state slot"); slot.write(&payload).expect("steady-state write"); let fake = FakeExternalBuffer::from_pooled(slot); std::hint::black_box(fake.data().len()); drop(fake); } set_tracking(false); let (count, bytes) = snapshot_counts(); assert_eq!( count, 0, "steady-state acquire/write/external/release must not allocate (got {count} allocations, {bytes} bytes)" ); } #[test] fn allocator_counter_self_test_reports_nonzero_when_allocating() { reset_counts(); set_tracking(true); let v: Vec = Vec::with_capacity(1024); set_tracking(false); let (count, _) = snapshot_counts(); let _hold = v; assert!( count >= 1, "self-test: allocator counter must record real allocs" ); } static FINALIZE_THREAD_COUNTER: AtomicUsize = AtomicUsize::new(0); struct ThreadAwareFakeBuffer { slot: Option, } impl Drop for ThreadAwareFakeBuffer { fn drop(&mut self) { FINALIZE_THREAD_COUNTER.fetch_add(1, Ordering::Relaxed); if let Some(slot) = self.slot.take() { drop(slot); } } } #[test] fn external_buffer_finalize_runs_exactly_once_per_frame() { FINALIZE_THREAD_COUNTER.store(0, Ordering::Relaxed); let pool = MacAudioFramePool::new(4, 64).expect("pool"); for _ in 0..50 { let mut slot = pool.try_acquire().expect("slot"); slot.write(&[0u8; 32]).expect("write"); let fake = ThreadAwareFakeBuffer { slot: Some(slot) }; drop(fake); } assert_eq!(FINALIZE_THREAD_COUNTER.load(Ordering::Relaxed), 50); assert_eq!(pool.stats().acquired, 50); assert_eq!(pool.stats().released, 50); } }