Files
fluxer/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs
T

2582 lines
85 KiB
Rust

// 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<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_waker_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_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<Option<Retained<NSError>>, 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<Option<Retained<SCShareableContent>>, 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<Retained<NSError>>,
content: Option<Retained<SCShareableContent>>,
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<Retained<SCShareableContent>, 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<AudioFrameTsfnInput, (), AudioFrameTsfnArgs, Status, false, false, 16>;
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<LifecycleTsfn>,
audio_diagnostic_tsfn: Option<AudioDiagnosticTsfn>,
encoder_diagnostic_tsfn: Option<EncoderDiagnosticTsfn>,
delegate: Option<Retained<FluxerSCKScreenSource>>,
stream: Option<Retained<SCStream>>,
sample_queue: Option<dispatch2::DispatchRetained<dispatch2::DispatchQueue>>,
}
unsafe impl Send for CaptureState {}
unsafe impl Sync for CaptureState {}
pub(crate) struct CaptureInner {
state: Mutex<CaptureState>,
audio_frame_tsfn: parking_lot::RwLock<Option<AudioFrameTsfn>>,
running: AtomicBool,
tsfn_aborted: AtomicBool,
capture_id: Mutex<Option<String>>,
bus_sink: parking_lot::RwLock<Option<Arc<dyn frame_bus::ScreenFrameSink>>>,
native_frame_sink: parking_lot::RwLock<Option<Arc<NativeScreenFrameSinkHandleRef>>>,
failure_surface: parking_lot::RwLock<Option<Arc<dyn CaptureFailureSurface>>>,
captures_audio: AtomicBool,
audio_sample_rate_hz: AtomicU32,
audio_channels: AtomicU32,
audio_pool: MacAudioFramePool,
encoder_attachment: parking_lot::RwLock<Option<Arc<EncoderAttachment>>>,
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<String>) -> napi::Error {
napi::Error::new(Status::GenericFailure, reason.into())
}
fn invalid_arg(reason: impl Into<String>) -> napi::Error {
napi::Error::new(Status::InvalidArg, reason.into())
}
fn lock_state(inner: &CaptureInner) -> Result<MutexGuard<'_, CaptureState>> {
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<CaptureInner>,
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<CaptureInner>) -> Retained<Self> {
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::<AblStorage>();
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<AudioFrameTsfnArgs> {
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<u8> = 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::<c_void>();
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<Arc<dyn frame_bus::ScreenFrameSink>> {
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<Arc<NativeScreenFrameSinkHandleRef>> {
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::<NativeScreenFrameSinkHandle>().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<CaptureInner>,
}
#[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<bool>,
#[napi(js_name = "captureRect")]
pub capture_rect: Option<ScreenCaptureRect>,
#[napi(js_name = "colorRange")]
pub color_range: Option<String>,
#[napi(js_name = "colorSpace")]
pub color_space: Option<String>,
#[napi(js_name = "maxOutputWidth")]
pub max_output_width: Option<u32>,
#[napi(js_name = "maxOutputHeight")]
pub max_output_height: Option<u32>,
}
#[derive(Clone, Copy, Debug)]
struct NormalizedStartOptions {
shows_cursor: bool,
source_rect: Option<CGRect>,
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<AudioFrameTsfnArgs, ()>,
) -> Result<()> {
let tsfn: AudioFrameTsfn = callback
.build_threadsafe_function::<AudioFrameTsfnInput>()
.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<AudioDiagnosticTsfnArgs, ()>,
) -> Result<()> {
let tsfn: AudioDiagnosticTsfn = callback
.build_threadsafe_function::<AudioDiagnosticTsfnArgs>()
.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<EncoderDiagnosticTsfnArgs, ()>,
) -> Result<()> {
let tsfn: EncoderDiagnosticTsfn = callback
.build_threadsafe_function::<EncoderDiagnosticTsfnArgs>()
.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<u32>) -> 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<dyn CaptureFailureSurface>) {
*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<String>,
capture_options: Option<ScreenCaptureStartOptions>,
) -> Result<ScreenCaptureStartResult> {
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<SCStreamConfiguration> = 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<SCStream> = 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<dispatch2::DispatchQueue> {
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<ScreenCaptureStartOptions>,
) -> Result<NormalizedStartOptions> {
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<u32>, 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<ScreenCaptureRect>) -> Result<Option<CGRect>> {
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<SckPixelFormat> {
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<SckColorSpace> {
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<CGRect>,
content_rect: Option<CGRect>,
point_pixel_scale: Option<f32>,
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<SCContentFilter>, (u32, u32))> {
match source_kind {
"screen" => {
let displays: Retained<NSArray<SCDisplay>> = unsafe { content.displays() };
let count = displays.count();
let mut chosen: Option<Retained<SCDisplay>> = 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<SCWindow>> = NSArray::new();
let filter_alloc = SCContentFilter::alloc();
let filter: Retained<SCContentFilter> = unsafe {
SCContentFilter::initWithDisplay_excludingWindows(filter_alloc, &display, &empty)
};
Ok((filter, dims))
}
"window" => {
let windows: Retained<NSArray<SCWindow>> = unsafe { content.windows() };
let count = windows.count();
let mut chosen: Option<Retained<SCWindow>> = 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<SCContentFilter> =
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<String>,
pub bundle_id: Option<String>,
pub target_pid: Option<i32>,
}
#[napi(js_name = "listSources")]
pub async fn list_sources() -> Result<Vec<MacScreenCaptureSource>> {
let content = match get_shareable_content(DEFAULT_TIMEOUT_NS) {
Ok(c) => c,
Err(_) => return Ok(Vec::new()),
};
let mut out: Vec<MacScreenCaptureSource> = Vec::new();
let displays: Retained<NSArray<SCDisplay>> = 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<NSArray<SCWindow>> = 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<String>,
}
#[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<BackendAvailability> {
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<String> {
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<String>,
#[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::<super::AblStorage>();
let abl_size = core::mem::size_of::<AudioBufferList>();
assert!(
storage_size > abl_size,
"AblStorage must hold > 1 AudioBuffer"
);
assert_eq!(core::mem::align_of::<super::AblStorage>(), 16);
}
#[test]
fn ablstorage_layout_matches_apple_abl() {
let storage_size = core::mem::size_of::<super::AblStorage>();
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<u64> = const { std::cell::Cell::new(0) };
static THREAD_ALLOC_BYTES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
static THREAD_TRACKING: std::cell::Cell<bool> = 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<PooledMacAudioFrame>,
}
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<FakeExternalBuffer> = 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<u8> = 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<PooledMacAudioFrame>,
}
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);
}
}