screencapturekit/stream/sc_stream.rs
1//! Swift FFI based `SCStream` implementation
2//!
3//! This is the primary (and only) implementation in v1.0+.
4//! All `ScreenCaptureKit` operations use direct Swift FFI bindings.
5//!
6//! Each stream owns a heap-allocated `StreamContext` that holds its output
7//! handlers and delegate. The context pointer is passed through FFI so that
8//! callbacks route directly to the owning stream — no global registries.
9
10use std::ffi::{c_void, CStr};
11use std::fmt;
12use std::num::NonZeroUsize;
13use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
14use std::sync::{Arc, Mutex, RwLock};
15
16use crate::error::{SCError, SCResult};
17use crate::stream::delegate_trait::SCStreamDelegateTrait;
18use crate::utils::completion::{is_timeout_error, UnitCompletion};
19use crate::utils::panic_safe::catch_user_panic;
20use crate::{
21 dispatch_queue::DispatchQueue,
22 ffi,
23 stream::{
24 configuration::SCStreamConfiguration, content_filter::SCContentFilter,
25 output_trait::SCStreamOutputTrait, output_type::SCStreamOutputType,
26 },
27};
28
29/// Per-stream handler entry.
30///
31/// The handler is behind an `Arc` so a callback can clone it out of the
32/// registry, release the lock, and only then run user code — see
33/// [`sample_handler`].
34struct HandlerEntry {
35 id: usize,
36 of_type: SCStreamOutputType,
37 handler: Arc<dyn SCStreamOutputTrait>,
38}
39
40/// The native dispatch queue established for one output type.
41///
42/// `ScreenCaptureKit` is given a single bridge-side `SCStreamOutput` object per
43/// stream, so there is exactly one native registration — and therefore one
44/// queue — per output type, no matter how many Rust handlers are attached.
45#[derive(Clone, Copy, PartialEq, Eq)]
46enum OutputQueue {
47 /// The dedicated user-interactive queue the bridge creates.
48 BridgeDefault,
49 /// A caller-supplied [`DispatchQueue`], identified by its raw pointer.
50 Custom(usize),
51}
52
53/// Per-stream context holding output handlers and an optional delegate.
54///
55/// Allocated on the heap via `Box::into_raw` and passed through FFI as an
56/// opaque context pointer. Callbacks cast it back to `&StreamContext` for
57/// direct, O(1) access to the owning stream's state.
58///
59/// `handlers` and `delegate` are stored behind `RwLock`s rather than
60/// `Mutex`es so concurrent callbacks from `ScreenCaptureKit`'s independent
61/// dispatch queues (e.g. screen + audio) can dispatch in parallel. The locks
62/// are held only long enough to clone out the `Arc`s that a callback needs;
63/// user code always runs unlocked.
64struct StreamContext {
65 handlers: RwLock<Vec<HandlerEntry>>,
66 delegate: RwLock<Option<Arc<dyn SCStreamDelegateTrait>>>,
67 /// Queue established by the first successful registration for each output
68 /// type, cleared when the last handler of that type is removed.
69 output_queues: RwLock<Vec<(SCStreamOutputType, OutputQueue)>>,
70 output_mutation: Mutex<()>,
71 capturing: Arc<AtomicBool>,
72 /// Set when a `start_capture` completion timed out, leaving `capturing`
73 /// latched without a confirmed outcome. The next start reissues instead of
74 /// short-circuiting to `Ok(())` on the strength of a start that may never
75 /// have landed.
76 start_unconfirmed: Arc<AtomicBool>,
77 #[cfg(feature = "macos_15_0")]
78 recording_outputs: AtomicUsize,
79 ref_count: AtomicUsize,
80}
81
82impl StreamContext {
83 fn new(delegate: Option<Arc<dyn SCStreamDelegateTrait>>) -> *mut Self {
84 let ctx = Box::new(Self {
85 handlers: RwLock::new(Vec::new()),
86 delegate: RwLock::new(delegate),
87 output_queues: RwLock::new(Vec::new()),
88 output_mutation: Mutex::new(()),
89 capturing: Arc::new(AtomicBool::new(false)),
90 start_unconfirmed: Arc::new(AtomicBool::new(false)),
91 #[cfg(feature = "macos_15_0")]
92 recording_outputs: AtomicUsize::new(0),
93 ref_count: AtomicUsize::new(1),
94 });
95 Box::into_raw(ctx)
96 }
97
98 /// Increment the reference count.
99 ///
100 /// # Safety
101 ///
102 /// `ptr` must point to a valid, live `StreamContext`.
103 unsafe fn retain(ptr: *mut Self) {
104 unsafe { &*ptr }.ref_count.fetch_add(1, Ordering::Relaxed);
105 }
106
107 /// Decrement the reference count, freeing the context if it reaches zero.
108 ///
109 /// # Safety
110 ///
111 /// `ptr` must point to a valid, live `StreamContext`. After this call,
112 /// `ptr` must not be used if the context was freed.
113 unsafe fn release(ptr: *mut Self) {
114 if ptr.is_null() {
115 return;
116 }
117 let prev = unsafe { &*ptr }.ref_count.fetch_sub(1, Ordering::Release);
118 if prev == 1 {
119 // The Acquire fence is required (NOT redundant — it pairs with
120 // the Release stores from other threads' `fetch_sub` calls
121 // and any other writes to `*ptr` they performed). It guarantees
122 // that the freeing thread sees all happened-before writes from
123 // every other thread that previously held a reference. This is
124 // the canonical Arc-style refcount drop pattern (see
125 // `std::sync::Arc::drop`); removing the fence is unsound on
126 // weakly-ordered architectures (e.g. AArch64).
127 std::sync::atomic::fence(Ordering::Acquire);
128 drop(unsafe { Box::from_raw(ptr) });
129 }
130 }
131
132 /// Clone the delegate out from under the lock so user code runs unlocked.
133 ///
134 /// Holding the lock across a delegate call would deadlock any delegate that
135 /// touches the stream, and would serialise otherwise-independent callbacks.
136 fn delegate_snapshot(&self) -> Option<Arc<dyn SCStreamDelegateTrait>> {
137 self.delegate
138 .read()
139 .unwrap_or_else(std::sync::PoisonError::into_inner)
140 .clone()
141 }
142
143 /// Clone the handlers matching `of_type` out from under the lock.
144 ///
145 /// A handler removed concurrently with a callback already in flight may
146 /// still receive that one sample — the `Arc` keeps it alive for the
147 /// duration — but never receives one afterwards.
148 fn handler_snapshot(&self, of_type: SCStreamOutputType) -> Vec<Arc<dyn SCStreamOutputTrait>> {
149 self.handlers
150 .read()
151 .unwrap_or_else(std::sync::PoisonError::into_inner)
152 .iter()
153 .filter(|e| e.of_type == of_type)
154 .map(|e| Arc::clone(&e.handler))
155 .collect()
156 }
157
158 #[cfg(feature = "macos_15_0")]
159 fn has_handlers(&self) -> bool {
160 !self
161 .handlers
162 .read()
163 .unwrap_or_else(std::sync::PoisonError::into_inner)
164 .is_empty()
165 }
166}
167
168/// Compile-time assertion: `StreamContext` is `Send + Sync`.
169///
170/// `SCStream` carries `unsafe impl Send + Sync` (lines below); that impl is
171/// only sound if the underlying `StreamContext` is itself `Send + Sync`.
172/// Without this static check, a future refactor that adds a `!Send` or
173/// `!Sync` field (or removes the `Send`/`Sync` bound from a trait it holds
174/// in `Box<dyn …>`) would silently invalidate the unsafe impl with no
175/// compiler error. This `const _` forces a compile error in that case.
176const _: fn() = || {
177 fn assert_send_sync<T: Send + Sync>() {}
178 assert_send_sync::<StreamContext>();
179};
180
181/// Monotonically increasing handler ID generator (process-wide).
182static NEXT_HANDLER_ID: AtomicUsize = AtomicUsize::new(1);
183
184/// Discriminant the Swift bridge uses for each output type.
185const fn native_output_type(of_type: SCStreamOutputType) -> i32 {
186 match of_type {
187 SCStreamOutputType::Screen => 0,
188 SCStreamOutputType::Audio => 1,
189 SCStreamOutputType::Microphone => 2,
190 }
191}
192
193// C trampoline handed to Swift so the bridge objects (delegate wrapper and
194// output handler) can each take a +1 reference on the `StreamContext` for the
195// duration of their own lifetime. This keeps the context alive while any
196// callback can still be dispatched on it.
197extern "C" fn context_retain_cb(context: *mut c_void) {
198 if !context.is_null() {
199 unsafe { StreamContext::retain(context.cast::<StreamContext>()) };
200 }
201}
202
203// C trampoline handed to Swift, invoked from each bridge object's `deinit` to
204// drop the +1 reference taken in `context_retain_cb`. `StreamContext::release`
205// null-checks internally.
206extern "C" fn context_release_cb(context: *mut c_void) {
207 catch_user_panic("StreamContext::release", || unsafe {
208 StreamContext::release(context.cast::<StreamContext>());
209 });
210}
211
212// C callback for stream errors — dispatches to per-stream delegate via context pointer.
213//
214// Safety: this function is called from Swift. A Rust panic unwinding across
215// the C ABI is undefined behavior, so all user-visible code (delegate trait
216// methods) is wrapped in `catch_unwind`. The `delegate` lock is taken with
217// `unwrap_or_else` poisoning recovery so a panic in one callback cannot
218// permanently break the stream by poisoning the lock.
219extern "C" fn delegate_error_callback(context: *mut c_void, error_code: i32, msg: *const i8) {
220 if context.is_null() {
221 return;
222 }
223 // SAFETY: `context` is the +1-retained StreamContext pointer the Swift
224 // bridge stored via context_retain_cb; it outlives this callback.
225 let ctx = unsafe { &*(context.cast::<StreamContext>()) };
226 ctx.capturing.store(false, Ordering::Release);
227
228 let message = if msg.is_null() {
229 "Unknown error".to_string()
230 } else {
231 // Best-effort: if Swift sent a non-UTF-8 buffer, fall back to a
232 // placeholder rather than panicking.
233 unsafe { CStr::from_ptr(msg) }
234 .to_str()
235 .unwrap_or("Unknown error")
236 .to_string()
237 };
238
239 let error = if error_code != 0 {
240 crate::error::SCStreamErrorCode::from_raw(error_code).map_or_else(
241 || SCError::StreamError(format!("{message} (code: {error_code})")),
242 |code| SCError::SCStreamError {
243 code,
244 message: Some(message.clone()),
245 },
246 )
247 } else {
248 SCError::StreamError(message)
249 };
250
251 let Some(delegate) = ctx.delegate_snapshot() else {
252 eprintln!("SCStream error: {error}");
253 return;
254 };
255
256 // ScreenCaptureKit reports stops only through `stream(_:didStopWithError:)`,
257 // so we dispatch the single canonical `did_stop_with_error` callback.
258 // Wrap user code in catch_unwind so a panic never propagates into Swift.
259 catch_user_panic("delegate.did_stop_with_error", || {
260 delegate.did_stop_with_error(error);
261 });
262}
263
264// C callback for the remaining `SCStreamDelegate` lifecycle events. The event
265// codes are defined alongside the trampoline in Stream.swift; the two lists
266// must stay in sync.
267extern "C" fn delegate_event_callback(context: *mut c_void, event: i32) {
268 if context.is_null() {
269 return;
270 }
271 // SAFETY: `context` is the +1-retained StreamContext pointer the Swift
272 // bridge stored via context_retain_cb; it outlives this callback.
273 let ctx = unsafe { &*(context.cast::<StreamContext>()) };
274
275 let Some(delegate) = ctx.delegate_snapshot() else {
276 return;
277 };
278
279 catch_user_panic("delegate lifecycle event", || match event {
280 0 => delegate.stream_did_become_active(),
281 1 => delegate.stream_did_become_inactive(),
282 2 => delegate.output_video_effect_did_start_for_stream(),
283 3 => delegate.output_video_effect_did_stop_for_stream(),
284 other => eprintln!("SCStream: unknown delegate event code {other}"),
285 });
286}
287
288// C callback for sample buffers — dispatches to per-stream handlers via context pointer.
289//
290// Safety: this function is called from Swift on a dispatch queue. A Rust
291// panic across the C ABI is UB; every user handler invocation is wrapped in
292// `catch_unwind`. The handler `Arc`s are cloned out under a short read lock
293// and dispatched with the lock released, so a handler is free to call back
294// into `add_output_handler` / `remove_output_handler` (which need the write
295// lock) without deadlocking, and a slow handler never blocks registration.
296// The `passRetained` `CMSampleBuffer` reference Swift hands us is consumed
297// exactly once: each non-final matching handler receives a freshly retained
298// clone, and the final matching handler consumes the original.
299extern "C" fn sample_handler(context: *mut c_void, sample_buffer: *const c_void, output_type: i32) {
300 if sample_buffer.is_null() {
301 return;
302 }
303 if context.is_null() {
304 unsafe { crate::cm::ffi::cm_sample_buffer_release(sample_buffer.cast_mut()) };
305 return;
306 }
307 // SAFETY: `context` is the +1-retained StreamContext pointer the Swift
308 // bridge stored via context_retain_cb; it outlives this callback.
309 let ctx = unsafe { &*(context.cast::<StreamContext>()) };
310
311 let output_type_enum = match output_type {
312 0 => SCStreamOutputType::Screen,
313 1 => SCStreamOutputType::Audio,
314 2 => SCStreamOutputType::Microphone,
315 _ => {
316 eprintln!("Unknown output type: {output_type}");
317 unsafe { crate::cm::ffi::cm_sample_buffer_release(sample_buffer.cast_mut()) };
318 return;
319 }
320 };
321
322 let matching = ctx.handler_snapshot(output_type_enum);
323
324 if matching.is_empty() {
325 unsafe { crate::cm::ffi::cm_sample_buffer_release(sample_buffer.cast_mut()) };
326 return;
327 }
328
329 let last = matching.len() - 1;
330 for (index, handler) in matching.iter().enumerate() {
331 // Retain for every handler except the last; the last handler consumes
332 // the original `passRetained` reference Swift gave us.
333 if index != last {
334 unsafe { crate::cm::ffi::cm_sample_buffer_retain(sample_buffer.cast_mut()) };
335 }
336
337 let buffer = unsafe { crate::cm::CMSampleBuffer::from_ptr(sample_buffer.cast_mut()) };
338
339 // Wrap user code in catch_unwind so panics never propagate into Swift.
340 // If the handler panics, `buffer` is dropped on unwind, which calls
341 // `cm_sample_buffer_release` and balances the retain we just did
342 // (or, for the last handler, balances the original `passRetained`).
343 // The retain/release accounting is preserved either way.
344 catch_user_panic("output handler", || {
345 handler.did_output_sample_buffer(buffer, output_type_enum);
346 });
347 }
348}
349
350/// Stable, non-owning identity for an [`SCStream`].
351///
352/// This is the address of the underlying native stream, stored as an opaque
353/// value. It does not keep the stream alive and must never be dereferenced.
354/// Compare it with [`SCStream::identity`] while the stream is still live.
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
356pub struct StreamIdentity(NonZeroUsize);
357
358impl StreamIdentity {
359 pub(crate) fn from_ptr(ptr: *const c_void) -> Option<Self> {
360 NonZeroUsize::new(ptr as usize).map(Self)
361 }
362
363 /// Whether this identity belongs to `stream`.
364 #[must_use]
365 pub fn matches(self, stream: &SCStream) -> bool {
366 self == stream.identity()
367 }
368}
369
370/// `SCStream` is a lightweight wrapper around the Swift `SCStream` instance.
371/// It provides direct FFI access to `ScreenCaptureKit` functionality.
372///
373/// This is the primary and only implementation of `SCStream` in v1.0+.
374/// All `ScreenCaptureKit` operations go through Swift FFI bindings.
375///
376/// # Examples
377///
378/// ```no_run
379/// use screencapturekit::prelude::*;
380///
381/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
382/// // Get shareable content
383/// let content = SCShareableContent::get()?;
384/// let display = &content.displays()[0];
385///
386/// // Create filter and configuration
387/// let filter = SCContentFilter::create()
388/// .with_display(display)
389/// .with_excluding_windows(&[])
390/// .build()?;
391/// let config = SCStreamConfiguration::new()
392/// .with_width(1920)
393/// .with_height(1080);
394///
395/// // Create and start stream
396/// let mut stream = SCStream::new(&filter, &config)?;
397/// stream.start_capture()?;
398///
399/// // ... capture frames ...
400///
401/// stream.stop_capture()?;
402/// # Ok(())
403/// # }
404/// ```
405pub struct SCStream {
406 ptr: *const c_void,
407 identity: StreamIdentity,
408 /// Per-stream context holding handlers and delegate (ref-counted).
409 context: *mut StreamContext,
410}
411
412unsafe impl Send for SCStream {}
413unsafe impl Sync for SCStream {}
414
415impl SCStream {
416 /// Create a new stream with a content filter and configuration
417 ///
418 /// # Examples
419 ///
420 /// ```no_run
421 /// use screencapturekit::prelude::*;
422 ///
423 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
424 /// let content = SCShareableContent::get()?;
425 /// let display = &content.displays()[0];
426 /// let filter = SCContentFilter::create()
427 /// .with_display(display)
428 /// .with_excluding_windows(&[])
429 /// .build()?;
430 /// let config = SCStreamConfiguration::new()
431 /// .with_width(1920)
432 /// .with_height(1080);
433 ///
434 /// let stream = SCStream::new(&filter, &config)?;
435 /// # Ok(())
436 /// # }
437 /// ```
438 #[allow(clippy::missing_errors_doc)]
439 pub fn new(filter: &SCContentFilter, configuration: &SCStreamConfiguration) -> SCResult<Self> {
440 Self::create(filter, configuration, None)
441 }
442
443 /// Create a new stream with a content filter, configuration, and delegate
444 ///
445 /// The delegate receives callbacks for stream lifecycle events. The key
446 /// one is [`did_stop_with_error`](crate::stream::delegate_trait::SCStreamDelegateTrait::did_stop_with_error),
447 /// invoked when `ScreenCaptureKit` stops the stream with an error (e.g. the
448 /// captured window closes or permission is revoked). A *clean* stop you
449 /// requested via [`stop_capture`](Self::stop_capture) is observed through
450 /// that call's return value, not the delegate.
451 ///
452 /// # Examples
453 ///
454 /// ```no_run
455 /// use screencapturekit::prelude::*;
456 /// use screencapturekit::stream::delegate_trait::StreamCallbacks;
457 ///
458 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
459 /// let content = SCShareableContent::get()?;
460 /// let display = &content.displays()[0];
461 /// let filter = SCContentFilter::create()
462 /// .with_display(display)
463 /// .with_excluding_windows(&[])
464 /// .build()?;
465 /// let config = SCStreamConfiguration::new()
466 /// .with_width(1920)
467 /// .with_height(1080);
468 ///
469 /// let delegate = StreamCallbacks::new()
470 /// .on_error(|e| eprintln!("Stream stopped with error: {}", e));
471 ///
472 /// let stream = SCStream::new_with_delegate(&filter, &config, delegate)?;
473 /// stream.start_capture()?;
474 /// # Ok(())
475 /// # }
476 /// ```
477 #[allow(clippy::missing_errors_doc)]
478 pub fn new_with_delegate(
479 filter: &SCContentFilter,
480 configuration: &SCStreamConfiguration,
481 delegate: impl SCStreamDelegateTrait + 'static,
482 ) -> SCResult<Self> {
483 Self::create(filter, configuration, Some(Arc::new(delegate)))
484 }
485
486 fn create(
487 filter: &SCContentFilter,
488 configuration: &SCStreamConfiguration,
489 delegate: Option<Arc<dyn SCStreamDelegateTrait>>,
490 ) -> SCResult<Self> {
491 let context = StreamContext::new(delegate);
492 let context_ptr = context.cast::<c_void>();
493 let configuration = configuration.clone();
494
495 let ptr = unsafe {
496 ffi::sc_stream_create(
497 filter.as_ptr(),
498 configuration.as_ptr(),
499 context_ptr,
500 delegate_error_callback,
501 sample_handler,
502 context_retain_cb,
503 context_release_cb,
504 )
505 };
506
507 unsafe { Self::adopt(ptr, context) }
508 }
509
510 unsafe fn adopt(ptr: *const c_void, context: *mut StreamContext) -> SCResult<Self> {
511 let Some(identity) = StreamIdentity::from_ptr(ptr) else {
512 unsafe { StreamContext::release(context) };
513 return Err(SCError::null_pointer(
514 "ScreenCaptureKit returned no SCStream",
515 ));
516 };
517
518 // Wire up the remaining delegate callbacks (active / inactive / video
519 // effect start / stop). Registration is unconditional: a delegate can
520 // be present from the start, and the Rust trampoline is a no-op when
521 // there isn't one.
522 unsafe { ffi::sc_stream_set_delegate_event_callback(ptr, delegate_event_callback) };
523
524 Ok(Self {
525 ptr,
526 identity,
527 context,
528 })
529 }
530
531 /// Add an output handler to receive captured frames
532 ///
533 /// # Arguments
534 ///
535 /// * `handler` - The handler to receive callbacks. Can be:
536 /// - A struct implementing [`SCStreamOutputTrait`]
537 /// - A closure `|CMSampleBuffer, SCStreamOutputType| { ... }`
538 /// * `of_type` - The type of output to receive (Screen, Audio, or Microphone)
539 ///
540 /// # Returns
541 ///
542 /// Returns the handler ID, which can be used with
543 /// [`remove_output_handler`](Self::remove_output_handler).
544 ///
545 /// # Errors
546 ///
547 /// Returns [`SCError::StreamError`] if `ScreenCaptureKit` rejected the
548 /// registration (e.g. the output type is not enabled by the stream
549 /// configuration), and [`SCError::InvalidConfiguration`] for the requests
550 /// [`add_output_handler_with_queue`](Self::add_output_handler_with_queue)
551 /// refuses.
552 ///
553 /// # Dispatch queue
554 ///
555 /// The handler is invoked on the queue already established for `of_type`,
556 /// or — for the first handler of that type — on a dedicated
557 /// user-interactive serial dispatch queue created by the bridge. This
558 /// intentionally **deviates from
559 /// Apple's `SCStream.addStreamOutput`** API, whose `nil` queue parameter
560 /// means "deliver on the main queue". Main-queue dispatch only works
561 /// when the host process runs a Cocoa runloop, which Rust apps
562 /// generally don't, so the default would otherwise silently drop
563 /// every frame. Use [`add_output_handler_with_queue`](Self::add_output_handler_with_queue)
564 /// and pass an explicit [`DispatchQueue`] (e.g. one wrapping main) if
565 /// you need a different queue — including AppKit/UIKit affinity.
566 ///
567 /// # Examples
568 ///
569 /// Using a struct:
570 /// ```rust,no_run
571 /// use screencapturekit::prelude::*;
572 ///
573 /// struct MyHandler;
574 /// impl SCStreamOutputTrait for MyHandler {
575 /// fn did_output_sample_buffer(&self, _sample: CMSampleBuffer, _of_type: SCStreamOutputType) {
576 /// println!("Got frame!");
577 /// }
578 /// }
579 ///
580 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
581 /// # let content = SCShareableContent::get()?;
582 /// # let display = &content.displays()[0];
583 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
584 /// # let config = SCStreamConfiguration::default();
585 /// let mut stream = SCStream::new(&filter, &config)?;
586 /// stream.add_output_handler(MyHandler, SCStreamOutputType::Screen)?;
587 /// # Ok(())
588 /// # }
589 /// ```
590 ///
591 /// Using a closure:
592 /// ```rust,no_run
593 /// use screencapturekit::prelude::*;
594 ///
595 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
596 /// # let content = SCShareableContent::get()?;
597 /// # let display = &content.displays()[0];
598 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
599 /// # let config = SCStreamConfiguration::default();
600 /// let mut stream = SCStream::new(&filter, &config)?;
601 /// stream.add_output_handler(
602 /// |_sample, _type| println!("Got frame!"),
603 /// SCStreamOutputType::Screen
604 /// )?;
605 /// # Ok(())
606 /// # }
607 /// ```
608 ///
609 /// # Sharing state with handlers
610 ///
611 /// The handler bound is `impl SCStreamOutputTrait + 'static`. The
612 /// `'static` is required because the handler is stored inside
613 /// `SCStream` which can outlive any borrowed reference. Combined
614 /// with the trait's `Send + Sync` bound (callbacks run on
615 /// independent dispatch queues, see [`SCStreamOutputTrait`]),
616 /// the canonical pattern for sharing state with a handler is to
617 /// wrap it in `Arc<Mutex<T>>` (or `Arc<AtomicXxx>` for primitives):
618 ///
619 /// ```rust,no_run
620 /// use screencapturekit::prelude::*;
621 /// use std::sync::{Arc, Mutex, atomic::{AtomicUsize, Ordering}};
622 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
623 /// # let content = SCShareableContent::get()?;
624 /// # let display = &content.displays()[0];
625 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
626 /// # let config = SCStreamConfiguration::default();
627 /// let frame_count = Arc::new(AtomicUsize::new(0));
628 /// let count_handler = frame_count.clone();
629 /// let mut stream = SCStream::new(&filter, &config)?;
630 /// stream.add_output_handler(
631 /// move |_sample, _type| {
632 /// count_handler.fetch_add(1, Ordering::Relaxed);
633 /// },
634 /// SCStreamOutputType::Screen,
635 /// )?;
636 /// // outer scope can still read frame_count any time:
637 /// println!("frames so far: {}", frame_count.load(Ordering::Relaxed));
638 /// # Ok(())
639 /// # }
640 /// ```
641 pub fn add_output_handler(
642 &mut self,
643 handler: impl SCStreamOutputTrait + 'static,
644 of_type: SCStreamOutputType,
645 ) -> Result<usize, SCError> {
646 self.add_output_handler_with_queue(handler, of_type, None)
647 }
648
649 /// Add an output handler with a custom dispatch queue
650 ///
651 /// This allows controlling which thread/queue the handler is called on.
652 ///
653 /// # Arguments
654 ///
655 /// * `handler` - The handler to receive callbacks
656 /// * `of_type` - The type of output to receive
657 /// * `queue` - Optional custom dispatch queue for callbacks
658 ///
659 /// # One queue per output type
660 ///
661 /// The bridge registers a single native `SCStreamOutput` object per output
662 /// type, so `ScreenCaptureKit` delivers **all** handlers of a given type on
663 /// **one** queue — the one established by the first successful registration
664 /// for that type. Consequences:
665 ///
666 /// - Adding a further handler for the same type with `queue: None` is fine:
667 /// it joins the established queue.
668 /// - Adding a further handler for the same type with a *different* explicit
669 /// queue is rejected with [`SCError::InvalidConfiguration`], rather than
670 /// silently delivering on a queue you did not ask for — that would break
671 /// handlers written around thread affinity.
672 /// - Removing the last handler of a type also tears down the native output,
673 /// so the next registration for that type is free to pick a new queue.
674 ///
675 /// # Examples
676 ///
677 /// ```rust,no_run
678 /// use screencapturekit::prelude::*;
679 /// use screencapturekit::dispatch_queue::{DispatchQueue, DispatchQoS};
680 ///
681 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
682 /// # let content = SCShareableContent::get()?;
683 /// # let display = &content.displays()[0];
684 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
685 /// # let config = SCStreamConfiguration::default();
686 /// let mut stream = SCStream::new(&filter, &config)?;
687 /// let queue = DispatchQueue::new("com.myapp.capture", DispatchQoS::UserInteractive);
688 ///
689 /// stream.add_output_handler_with_queue(
690 /// |_sample, _type| println!("Got frame on custom queue!"),
691 /// SCStreamOutputType::Screen,
692 /// Some(&queue)
693 /// )?;
694 /// # Ok(())
695 /// # }
696 /// ```
697 #[allow(clippy::missing_errors_doc)]
698 pub fn add_output_handler_with_queue(
699 &mut self,
700 handler: impl SCStreamOutputTrait + 'static,
701 of_type: SCStreamOutputType,
702 queue: Option<&DispatchQueue>,
703 ) -> Result<usize, SCError> {
704 #[cfg(not(feature = "macos_15_0"))]
705 if of_type == SCStreamOutputType::Microphone {
706 return Err(SCError::invalid_config(
707 "microphone output requires the macos_15_0 feature",
708 ));
709 }
710
711 let requested = queue.map_or(OutputQueue::BridgeDefault, |q| {
712 OutputQueue::Custom(q.as_ptr() as usize)
713 });
714
715 // SAFETY: self.context is the Box::into_raw StreamContext created in
716 // SCStream::create; it stays valid for the lifetime of self (released
717 // only in Drop, after this method returns).
718 let ctx = unsafe { &*self.context };
719 let _mutation_guard = ctx
720 .output_mutation
721 .lock()
722 .unwrap_or_else(std::sync::PoisonError::into_inner);
723
724 let mut established = ctx
725 .output_queues
726 .write()
727 .unwrap_or_else(std::sync::PoisonError::into_inner);
728
729 if let Some(&(_, existing)) = established.iter().find(|(ty, _)| *ty == of_type) {
730 if queue.is_some() && existing != requested {
731 drop(established);
732 return Err(SCError::invalid_config(format!(
733 "refusing to add a {of_type:?} handler on a different dispatch queue — \
734 ScreenCaptureKit delivers every handler of one output type on the queue \
735 chosen by the first registration. Reuse that queue (pass None) or remove \
736 the existing {of_type:?} handlers first."
737 )));
738 }
739 }
740
741 let output_type_int = native_output_type(of_type);
742
743 let ok = if let Some(q) = queue {
744 unsafe {
745 ffi::sc_stream_add_stream_output_with_queue(self.ptr, output_type_int, q.as_ptr())
746 }
747 } else {
748 unsafe { ffi::sc_stream_add_stream_output(self.ptr, output_type_int) }
749 };
750
751 if !ok {
752 drop(established);
753 return Err(SCError::StreamError(format!(
754 "failed to register output handler for {of_type:?} \
755 (ScreenCaptureKit rejected addStreamOutput)"
756 )));
757 }
758
759 if !established.iter().any(|(ty, _)| *ty == of_type) {
760 established.push((of_type, requested));
761 }
762 drop(established);
763
764 let handler_id = NEXT_HANDLER_ID.fetch_add(1, Ordering::Relaxed);
765 ctx.handlers
766 .write()
767 .unwrap_or_else(std::sync::PoisonError::into_inner)
768 .push(HandlerEntry {
769 id: handler_id,
770 of_type,
771 handler: Arc::new(handler),
772 });
773 Ok(handler_id)
774 }
775
776 /// Remove an output handler
777 ///
778 /// # Arguments
779 ///
780 /// * `id` - The handler ID returned from [`add_output_handler`](Self::add_output_handler)
781 /// * `of_type` - The type of output the handler was registered for
782 ///
783 /// # Returns
784 ///
785 /// Returns `Ok(true)` if a handler with this `id` **and** output type was
786 /// found and removed, and `Ok(false)` if there was no such handler.
787 ///
788 /// # Errors
789 ///
790 /// Returns [`SCError::StreamError`] when the handler was removed from this
791 /// stream but `removeStreamOutput` failed on the `ScreenCaptureKit` side.
792 /// The handler stops receiving samples either way; a native teardown
793 /// failure only means `ScreenCaptureKit` keeps delivering samples that the
794 /// bridge then discards.
795 pub fn remove_output_handler(
796 &mut self,
797 id: usize,
798 of_type: SCStreamOutputType,
799 ) -> Result<bool, SCError> {
800 // SAFETY: self.context is the Box::into_raw StreamContext created in
801 // SCStream::create; it stays valid for the lifetime of self.
802 let ctx = unsafe { &*self.context };
803 let mutation_guard = ctx
804 .output_mutation
805 .lock()
806 .unwrap_or_else(std::sync::PoisonError::into_inner);
807
808 let mut handlers = ctx
809 .handlers
810 .write()
811 .unwrap_or_else(std::sync::PoisonError::into_inner);
812 // Match on the output type too: the same id registered for a different
813 // type must not be silently removed by a mismatched call, which would
814 // also detach the wrong native output.
815 let Some(pos) = handlers
816 .iter()
817 .position(|e| e.id == id && e.of_type == of_type)
818 else {
819 return Ok(false);
820 };
821 let removed_handler = handlers.remove(pos);
822
823 // If no more handlers for this output type, tell Swift to remove the output
824 let has_type = handlers.iter().any(|e| e.of_type == of_type);
825 drop(handlers);
826
827 let result = if has_type {
828 Ok(true)
829 } else {
830 let removed = unsafe {
831 ffi::sc_stream_remove_stream_output(self.ptr, native_output_type(of_type))
832 };
833
834 if removed {
835 // The next registration for this type may choose a fresh queue.
836 ctx.output_queues
837 .write()
838 .unwrap_or_else(std::sync::PoisonError::into_inner)
839 .retain(|(ty, _)| *ty != of_type);
840 Ok(true)
841 } else {
842 Err(SCError::StreamError(format!(
843 "ScreenCaptureKit rejected removeStreamOutput for {of_type:?}; the handler was \
844 detached but the native output is still registered"
845 )))
846 }
847 };
848
849 drop(mutation_guard);
850 drop(removed_handler);
851 result
852 }
853
854 /// Start capturing screen content
855 ///
856 /// This method blocks until the capture operation completes or fails.
857 ///
858 /// # Errors
859 ///
860 /// Returns `SCError::CaptureStartFailed` if the capture fails to start.
861 pub fn start_capture(&self) -> Result<(), SCError> {
862 let context = unsafe { &*self.context };
863 if !claim_start(&context.capturing, &context.start_unconfirmed) {
864 return Ok(());
865 }
866 let (completion, context) = UnitCompletion::new();
867 unsafe { ffi::sc_stream_start_capture(self.ptr, context, UnitCompletion::callback) };
868 match completion.wait() {
869 Ok(()) => Ok(()),
870 Err(error) => {
871 let context = unsafe { &*self.context };
872 if is_timeout_error(&error) {
873 // The native start is still outstanding, so clearing
874 // `capturing` would let a retry double-start. Record that
875 // the outcome is unconfirmed instead, so the next start
876 // reissues rather than reporting a success nobody observed.
877 context.start_unconfirmed.store(true, Ordering::Release);
878 } else {
879 context.capturing.store(false, Ordering::Release);
880 }
881 Err(SCError::CaptureStartFailed(error))
882 }
883 }
884 }
885
886 /// Stop capturing screen content
887 ///
888 /// This method blocks until the capture operation completes or fails.
889 ///
890 /// # Errors
891 ///
892 /// Returns `SCError::CaptureStopFailed` if the capture fails to stop.
893 pub fn stop_capture(&self) -> Result<(), SCError> {
894 let context = unsafe { &*self.context };
895 if !context.capturing.swap(false, Ordering::AcqRel) {
896 return Ok(());
897 }
898 let (completion, context) = UnitCompletion::new();
899 unsafe { ffi::sc_stream_stop_capture(self.ptr, context, UnitCompletion::callback) };
900 if let Err(error) = completion.wait() {
901 unsafe { &*self.context }
902 .capturing
903 .store(true, Ordering::Release);
904 return Err(SCError::CaptureStopFailed(error));
905 }
906 Ok(())
907 }
908
909 /// Update the stream configuration
910 ///
911 /// This method blocks until the configuration update completes or fails.
912 ///
913 /// # Errors
914 ///
915 /// Returns `SCError::StreamError` if the configuration update fails.
916 #[cfg(feature = "macos_14_0")]
917 pub fn update_configuration(
918 &self,
919 configuration: &SCStreamConfiguration,
920 ) -> Result<(), SCError> {
921 let configuration = configuration.clone();
922 let (completion, context) = UnitCompletion::new();
923 unsafe {
924 ffi::sc_stream_update_configuration(
925 self.ptr,
926 configuration.as_ptr(),
927 context,
928 UnitCompletion::callback,
929 );
930 }
931 completion.wait().map_err(SCError::StreamError)
932 }
933
934 /// Update the content filter
935 ///
936 /// This method blocks until the filter update completes or fails.
937 ///
938 /// # Errors
939 ///
940 /// Returns `SCError::StreamError` if the filter update fails.
941 pub fn update_content_filter(&self, filter: &SCContentFilter) -> Result<(), SCError> {
942 let (completion, context) = UnitCompletion::new();
943 unsafe {
944 ffi::sc_stream_update_content_filter(
945 self.ptr,
946 filter.as_ptr(),
947 context,
948 UnitCompletion::callback,
949 );
950 }
951 completion.wait().map_err(SCError::StreamError)
952 }
953
954 /// Get the synchronization clock for this stream (macOS 13.0+)
955 ///
956 /// Returns the `CMClock` used to synchronize the stream's output.
957 /// This is useful for coordinating multiple streams or synchronizing
958 /// with other media.
959 ///
960 /// Returns `None` if the clock is not available (e.g., stream not started
961 /// or macOS version too old).
962 #[cfg(feature = "macos_13_0")]
963 pub fn synchronization_clock(&self) -> Option<crate::cm::CMClock> {
964 let ptr = unsafe { ffi::sc_stream_get_synchronization_clock(self.ptr) };
965 // SAFETY: the Swift thunk transfers a +1 retained clock reference.
966 #[allow(unused_unsafe)]
967 unsafe {
968 crate::cm::CMClock::from_raw(ptr)
969 }
970 }
971
972 /// Add a recording output to the stream (macOS 15.0+)
973 ///
974 /// Starts recording if the stream is already capturing, otherwise recording
975 /// will start when capture begins. The recording is written to the file URL
976 /// specified in the `SCRecordingOutputConfiguration`.
977 ///
978 /// # Errors
979 ///
980 /// Returns `SCError::StreamError` if adding the recording output fails.
981 #[cfg(feature = "macos_15_0")]
982 pub fn add_recording_output(
983 &self,
984 recording_output: &crate::recording_output::SCRecordingOutput,
985 ) -> Result<(), SCError> {
986 let stream_context = unsafe { &*self.context };
987 let (completion, context) = UnitCompletion::new();
988 unsafe {
989 ffi::sc_stream_add_recording_output(
990 self.ptr,
991 recording_output.as_ptr(),
992 UnitCompletion::callback,
993 context,
994 );
995 }
996 completion.wait().map_err(SCError::StreamError)?;
997 stream_context
998 .recording_outputs
999 .fetch_add(1, Ordering::AcqRel);
1000 Ok(())
1001 }
1002
1003 /// Remove a recording output from the stream (macOS 15.0+)
1004 ///
1005 /// Stops recording if the stream is currently recording.
1006 ///
1007 /// # Errors
1008 ///
1009 /// Returns `SCError::StreamError` if removing the recording output fails.
1010 #[cfg(feature = "macos_15_0")]
1011 pub fn remove_recording_output(
1012 &self,
1013 recording_output: &crate::recording_output::SCRecordingOutput,
1014 ) -> Result<(), SCError> {
1015 let context = unsafe { &*self.context };
1016 if context.capturing.load(Ordering::Acquire)
1017 && !context.has_handlers()
1018 && context.recording_outputs.load(Ordering::Acquire) == 1
1019 {
1020 self.stop_capture()?;
1021 let (completion, completion_context) = UnitCompletion::new();
1022 unsafe {
1023 ffi::sc_recording_output_wait_until_terminal(
1024 recording_output.as_ptr(),
1025 completion_context,
1026 UnitCompletion::callback,
1027 );
1028 }
1029 completion.wait().map_err(SCError::StreamError)?;
1030 }
1031 let (completion, completion_context) = UnitCompletion::new();
1032 unsafe {
1033 ffi::sc_stream_remove_recording_output(
1034 self.ptr,
1035 recording_output.as_ptr(),
1036 UnitCompletion::callback,
1037 completion_context,
1038 );
1039 }
1040 let outcome = completion.wait();
1041 // Swift reports failure only when the native removal itself threw; a
1042 // timeout can therefore only expire while waiting for the movie to
1043 // finalize, by which point the output is already gone. Leaving the
1044 // count inflated there would permanently disable the stop-and-flush
1045 // branch above for whichever outputs remain.
1046 let removed = match &outcome {
1047 Ok(()) => true,
1048 Err(error) => is_timeout_error(error),
1049 };
1050 if removed {
1051 context
1052 .recording_outputs
1053 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
1054 Some(count.saturating_sub(1))
1055 })
1056 .ok();
1057 }
1058 outcome.map_err(SCError::StreamError)?;
1059 Ok(())
1060 }
1061
1062 /// Returns the raw pointer to the underlying Swift `SCStream` instance.
1063 #[allow(dead_code)]
1064 pub(crate) fn as_ptr(&self) -> *const c_void {
1065 self.ptr
1066 }
1067
1068 /// Return a stable, non-owning identity for this stream.
1069 #[must_use]
1070 pub fn identity(&self) -> StreamIdentity {
1071 self.identity
1072 }
1073
1074 #[cfg(feature = "async")]
1075 pub(crate) fn capture_state(&self) -> Arc<AtomicBool> {
1076 Arc::clone(&unsafe { &*self.context }.capturing)
1077 }
1078
1079 #[cfg(feature = "async")]
1080 pub(crate) fn start_unconfirmed_state(&self) -> Arc<AtomicBool> {
1081 Arc::clone(&unsafe { &*self.context }.start_unconfirmed)
1082 }
1083}
1084
1085/// Claims the right to issue a native `startCapture`.
1086///
1087/// Returns `false` when the stream is already capturing and the previous start
1088/// was confirmed, so the caller can report success without a redundant FFI
1089/// call. `start_unconfirmed` is always cleared, since a flag left set past a
1090/// successful start would later reissue a start on a live stream.
1091pub(crate) fn claim_start(capturing: &AtomicBool, start_unconfirmed: &AtomicBool) -> bool {
1092 let was_capturing = capturing.swap(true, Ordering::AcqRel);
1093 let unconfirmed = start_unconfirmed.swap(false, Ordering::AcqRel);
1094 !was_capturing || unconfirmed
1095}
1096
1097impl Drop for SCStream {
1098 // Safety / teardown ordering:
1099 //
1100 // `sc_stream_release` drops this handle's claim on the Swift-side
1101 // `StreamState` and releases the `SCStream`. The `StreamState` — and with
1102 // it the stream-output delegate — is discarded only when the *last* handle
1103 // (this stream plus every clone) is gone, so dropping one clone never
1104 // detaches the callbacks of the survivors. We release the `StreamContext`
1105 // afterwards, so the ordering here is release-stream-then-release-context.
1106 //
1107 // In-flight callbacks are safe even though Apple's stop is asynchronous: the
1108 // Swift `StreamDelegateWrapper` and `StreamOutputHandler` objects each hold
1109 // their own +1 reference on the `StreamContext` (taken in `init` via
1110 // `context_retain_cb`, dropped in `deinit` via `context_release_cb`). Each
1111 // callback runs as a method on one of those objects, and ARC keeps that
1112 // object (`self`) alive for the duration of the call, so its context
1113 // reference is also held for the duration of the call. Therefore a callback
1114 // already in flight can never observe a freed context: the final
1115 // `Box::from_raw` only happens once every holder — this Rust `SCStream` and
1116 // both Swift bridge objects — has released its reference.
1117 //
1118 // Refcount accounting: `StreamContext::new` starts at 1; the Swift
1119 // `createStream` adds +1 per bridge object (delegate + output handler) = 3;
1120 // each Rust clone adds +1; each `drop` removes -1; each bridge object's
1121 // `deinit` removes -1, and the context is freed when the total reaches 0.
1122 fn drop(&mut self) {
1123 unsafe { ffi::sc_stream_release(self.ptr) };
1124 unsafe { StreamContext::release(self.context) };
1125 }
1126}
1127
1128impl Clone for SCStream {
1129 /// Clone the stream reference.
1130 ///
1131 /// Cloning an `SCStream` creates a new reference to the same underlying
1132 /// Swift `SCStream` object. The cloned stream shares the same handlers
1133 /// as the original — they receive frames from the same capture session.
1134 ///
1135 /// Both the original and cloned stream share the same capture state, so:
1136 /// - Starting capture on one affects both
1137 /// - Stopping capture on one affects both
1138 /// - Configuration updates affect both
1139 /// - Handlers receive the same frames
1140 /// - Dropping one clone leaves the others fully functional; capture and
1141 /// delegate callbacks stop only once the last clone is dropped
1142 ///
1143 /// # Examples
1144 ///
1145 /// ```rust,no_run
1146 /// use screencapturekit::prelude::*;
1147 ///
1148 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1149 /// # let content = SCShareableContent::get()?;
1150 /// # let display = &content.displays()[0];
1151 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
1152 /// # let config = SCStreamConfiguration::default();
1153 /// let mut stream = SCStream::new(&filter, &config)?;
1154 /// stream.add_output_handler(|_, _| println!("Handler 1"), SCStreamOutputType::Screen)?;
1155 ///
1156 /// // Clone shares the same handlers
1157 /// let stream2 = stream.clone();
1158 /// // Both stream and stream2 will receive frames via Handler 1
1159 /// # Ok(())
1160 /// # }
1161 /// ```
1162 fn clone(&self) -> Self {
1163 unsafe { StreamContext::retain(self.context) };
1164
1165 Self {
1166 ptr: unsafe { crate::ffi::sc_stream_retain(self.ptr) },
1167 identity: self.identity,
1168 context: self.context,
1169 }
1170 }
1171}
1172
1173impl fmt::Debug for SCStream {
1174 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1175 f.debug_struct("SCStream")
1176 .field("ptr", &self.ptr)
1177 .finish_non_exhaustive()
1178 }
1179}
1180
1181impl fmt::Display for SCStream {
1182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1183 write!(f, "SCStream")
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190 use std::sync::atomic::AtomicUsize;
1191 use std::sync::Arc;
1192
1193 /// Regression test for #135: multiple concurrent streams must not leak
1194 /// samples across each other.
1195 ///
1196 /// Creates two independent `StreamContexts` with separate handlers and
1197 /// directly invokes each context's handlers. Verifies that each handler
1198 /// only receives calls routed through its own context — not from the
1199 /// other context. With the old global `HANDLER_REGISTRY`, both handlers
1200 /// would have been called for every callback regardless of context.
1201 #[test]
1202 fn test_per_stream_callback_isolation() {
1203 let count_a = Arc::new(AtomicUsize::new(0));
1204 let count_b = Arc::new(AtomicUsize::new(0));
1205
1206 // Create two independent contexts (simulates two SCStream instances)
1207 let ctx_a = StreamContext::new(None);
1208 let ctx_b = StreamContext::new(None);
1209
1210 // Register an audio handler on context A
1211 {
1212 let counter = count_a.clone();
1213 let mut handlers = unsafe { &*ctx_a }
1214 .handlers
1215 .write()
1216 .unwrap_or_else(std::sync::PoisonError::into_inner);
1217 handlers.push(HandlerEntry {
1218 id: 1,
1219 of_type: SCStreamOutputType::Audio,
1220 handler: Arc::new(
1221 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1222 counter.fetch_add(1, Ordering::Relaxed);
1223 // Prevent Drop from calling cm_sample_buffer_release on our fake pointer
1224 std::mem::forget(buf);
1225 },
1226 ),
1227 });
1228 }
1229
1230 // Register an audio handler on context B
1231 {
1232 let counter = count_b.clone();
1233 let mut handlers = unsafe { &*ctx_b }
1234 .handlers
1235 .write()
1236 .unwrap_or_else(std::sync::PoisonError::into_inner);
1237 handlers.push(HandlerEntry {
1238 id: 2,
1239 of_type: SCStreamOutputType::Audio,
1240 handler: Arc::new(
1241 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1242 counter.fetch_add(1, Ordering::Relaxed);
1243 std::mem::forget(buf);
1244 },
1245 ),
1246 });
1247 }
1248
1249 // Simulate 5 audio callbacks on context A by directly calling matching handlers
1250 for _ in 0..5 {
1251 let handlers = unsafe { &*ctx_a }
1252 .handlers
1253 .write()
1254 .unwrap_or_else(std::sync::PoisonError::into_inner);
1255 for entry in handlers
1256 .iter()
1257 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1258 {
1259 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1260 entry
1261 .handler
1262 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1263 }
1264 }
1265
1266 // Simulate 3 audio callbacks on context B
1267 for _ in 0..3 {
1268 let handlers = unsafe { &*ctx_b }
1269 .handlers
1270 .write()
1271 .unwrap_or_else(std::sync::PoisonError::into_inner);
1272 for entry in handlers
1273 .iter()
1274 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1275 {
1276 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1277 entry
1278 .handler
1279 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1280 }
1281 }
1282
1283 // Handler A must have received exactly 5 — not 8
1284 assert_eq!(
1285 count_a.load(Ordering::Relaxed),
1286 5,
1287 "handler A received callbacks meant for B (cross-stream leak)"
1288 );
1289 // Handler B must have received exactly 3 — not 8
1290 assert_eq!(
1291 count_b.load(Ordering::Relaxed),
1292 3,
1293 "handler B received callbacks meant for A (cross-stream leak)"
1294 );
1295
1296 unsafe {
1297 StreamContext::release(ctx_a);
1298 StreamContext::release(ctx_b);
1299 }
1300 }
1301
1302 /// Verify that handlers are filtered by output type within a single context.
1303 #[test]
1304 fn test_handler_output_type_filtering() {
1305 let screen_count = Arc::new(AtomicUsize::new(0));
1306 let audio_count = Arc::new(AtomicUsize::new(0));
1307
1308 let ctx = StreamContext::new(None);
1309
1310 {
1311 let counter = screen_count.clone();
1312 let mut handlers = unsafe { &*ctx }
1313 .handlers
1314 .write()
1315 .unwrap_or_else(std::sync::PoisonError::into_inner);
1316 handlers.push(HandlerEntry {
1317 id: 1,
1318 of_type: SCStreamOutputType::Screen,
1319 handler: Arc::new(
1320 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1321 counter.fetch_add(1, Ordering::Relaxed);
1322 std::mem::forget(buf);
1323 },
1324 ),
1325 });
1326 }
1327 {
1328 let counter = audio_count.clone();
1329 let mut handlers = unsafe { &*ctx }
1330 .handlers
1331 .write()
1332 .unwrap_or_else(std::sync::PoisonError::into_inner);
1333 handlers.push(HandlerEntry {
1334 id: 2,
1335 of_type: SCStreamOutputType::Audio,
1336 handler: Arc::new(
1337 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1338 counter.fetch_add(1, Ordering::Relaxed);
1339 std::mem::forget(buf);
1340 },
1341 ),
1342 });
1343 }
1344
1345 // Send 4 screen callbacks
1346 for _ in 0..4 {
1347 let handlers = unsafe { &*ctx }
1348 .handlers
1349 .write()
1350 .unwrap_or_else(std::sync::PoisonError::into_inner);
1351 for entry in handlers
1352 .iter()
1353 .filter(|e| e.of_type == SCStreamOutputType::Screen)
1354 {
1355 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1356 entry
1357 .handler
1358 .did_output_sample_buffer(buf, SCStreamOutputType::Screen);
1359 }
1360 }
1361
1362 // Send 2 audio callbacks
1363 for _ in 0..2 {
1364 let handlers = unsafe { &*ctx }
1365 .handlers
1366 .write()
1367 .unwrap_or_else(std::sync::PoisonError::into_inner);
1368 for entry in handlers
1369 .iter()
1370 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1371 {
1372 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1373 entry
1374 .handler
1375 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1376 }
1377 }
1378
1379 assert_eq!(screen_count.load(Ordering::Relaxed), 4);
1380 assert_eq!(audio_count.load(Ordering::Relaxed), 2);
1381
1382 unsafe { StreamContext::release(ctx) };
1383 }
1384
1385 /// Verify that `StreamContext` ref counting works correctly.
1386 #[test]
1387 fn test_stream_context_ref_counting() {
1388 let ctx = StreamContext::new(None);
1389
1390 // Initial ref count is 1
1391 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 1);
1392
1393 // Retain bumps to 2
1394 unsafe { StreamContext::retain(ctx) };
1395 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 2);
1396
1397 // First release drops to 1 — context still alive
1398 unsafe { StreamContext::release(ctx) };
1399 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 1);
1400
1401 // Second release drops to 0 — context freed (no crash = success)
1402 unsafe { StreamContext::release(ctx) };
1403 }
1404
1405 /// Regression test: a panic in a user-supplied output handler must NOT
1406 /// poison the handlers `RwLock`, must NOT propagate across the C ABI,
1407 /// and must NOT prevent subsequent callbacks from being dispatched.
1408 ///
1409 /// This validates the C1+C2 fix from the deep review: `catch_unwind`
1410 /// around user dispatch and `RwLock` poisoning recovery via
1411 /// `unwrap_or_else(PoisonError::into_inner)` together prevent one
1412 /// panicking handler from permanently breaking the stream.
1413 #[test]
1414 fn test_panic_in_handler_is_isolated() {
1415 // Set a no-op panic hook so our intentional panic doesn't spam the
1416 // test output. We restore it at the end of the test.
1417 let original_hook = std::panic::take_hook();
1418 std::panic::set_hook(Box::new(|_| {}));
1419
1420 let panicked_count = Arc::new(AtomicUsize::new(0));
1421 let normal_count = Arc::new(AtomicUsize::new(0));
1422
1423 let ctx = StreamContext::new(None);
1424
1425 // Handler 1: always panics
1426 {
1427 let counter = panicked_count.clone();
1428 let mut handlers = unsafe { &*ctx }
1429 .handlers
1430 .write()
1431 .unwrap_or_else(std::sync::PoisonError::into_inner);
1432 handlers.push(HandlerEntry {
1433 id: 1,
1434 of_type: SCStreamOutputType::Audio,
1435 handler: Arc::new(
1436 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1437 counter.fetch_add(1, Ordering::Relaxed);
1438 std::mem::forget(buf);
1439 panic!("intentional test panic");
1440 },
1441 ),
1442 });
1443 }
1444
1445 // Handler 2: well-behaved, registered AFTER the panicker
1446 {
1447 let counter = normal_count.clone();
1448 let mut handlers = unsafe { &*ctx }
1449 .handlers
1450 .write()
1451 .unwrap_or_else(std::sync::PoisonError::into_inner);
1452 handlers.push(HandlerEntry {
1453 id: 2,
1454 of_type: SCStreamOutputType::Audio,
1455 handler: Arc::new(
1456 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1457 counter.fetch_add(1, Ordering::Relaxed);
1458 std::mem::forget(buf);
1459 },
1460 ),
1461 });
1462 }
1463
1464 // Simulate 5 callbacks. Each iteration, the panicker fires (and
1465 // panics), then the well-behaved handler must still fire on the
1466 // SAME callback because both handlers match the output type. We
1467 // simulate the dispatch path without going through the C callback
1468 // (which would require a real CMSampleBuffer); the key behaviour
1469 // we're verifying is that the lock isn't poisoned and that the
1470 // catch_unwind boundary contains the panic.
1471 for _ in 0..5 {
1472 let handlers = unsafe { &*ctx }
1473 .handlers
1474 .read()
1475 .unwrap_or_else(std::sync::PoisonError::into_inner);
1476 for entry in handlers
1477 .iter()
1478 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1479 {
1480 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1481 catch_user_panic("test handler", || {
1482 entry
1483 .handler
1484 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1485 });
1486 }
1487 }
1488
1489 // Both handlers fired 5 times each — the panicker did not stop the
1490 // dispatch loop or poison the lock for subsequent reads.
1491 assert_eq!(
1492 panicked_count.load(Ordering::Relaxed),
1493 5,
1494 "panicking handler stopped firing after first panic"
1495 );
1496 assert_eq!(
1497 normal_count.load(Ordering::Relaxed),
1498 5,
1499 "well-behaved handler stopped firing after panicker poisoned state"
1500 );
1501
1502 // Lock is still acquirable (would otherwise be poisoned).
1503 drop(
1504 unsafe { &*ctx }
1505 .handlers
1506 .write()
1507 .unwrap_or_else(std::sync::PoisonError::into_inner),
1508 );
1509
1510 unsafe { StreamContext::release(ctx) };
1511
1512 // Restore the original panic hook so other tests behave normally.
1513 std::panic::set_hook(original_hook);
1514 }
1515
1516 /// Regression test: dispatching a sample must not hold the handlers lock,
1517 /// so a handler is free to mutate the handler set from inside the
1518 /// callback. Under the previous read-lock-across-dispatch design this
1519 /// deadlocked on the `RwLock` upgrade.
1520 #[test]
1521 fn test_handler_may_take_the_write_lock_from_inside_a_callback() {
1522 struct Reentrant(*mut StreamContext);
1523 // SAFETY: the pointer is only used to take the same locks the real
1524 // callback path takes; the context outlives the handler in this test.
1525 unsafe impl Send for Reentrant {}
1526 unsafe impl Sync for Reentrant {}
1527
1528 impl SCStreamOutputTrait for Reentrant {
1529 fn did_output_sample_buffer(
1530 &self,
1531 buffer: crate::cm::CMSampleBuffer,
1532 _of_type: SCStreamOutputType,
1533 ) {
1534 std::mem::forget(buffer);
1535 // Would deadlock if the dispatch path still held a read lock.
1536 let mut handlers = unsafe { &*self.0 }
1537 .handlers
1538 .write()
1539 .unwrap_or_else(std::sync::PoisonError::into_inner);
1540 handlers.retain(|e| e.id != 1);
1541 }
1542 }
1543
1544 let ctx = StreamContext::new(None);
1545 unsafe { &*ctx }
1546 .handlers
1547 .write()
1548 .unwrap_or_else(std::sync::PoisonError::into_inner)
1549 .push(HandlerEntry {
1550 id: 1,
1551 of_type: SCStreamOutputType::Screen,
1552 handler: Arc::new(Reentrant(ctx)),
1553 });
1554
1555 for handler in unsafe { &*ctx }.handler_snapshot(SCStreamOutputType::Screen) {
1556 let buffer = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1557 handler.did_output_sample_buffer(buffer, SCStreamOutputType::Screen);
1558 }
1559
1560 assert!(
1561 unsafe { &*ctx }
1562 .handler_snapshot(SCStreamOutputType::Screen)
1563 .is_empty(),
1564 "handler failed to remove itself from inside its own callback"
1565 );
1566
1567 unsafe { StreamContext::release(ctx) };
1568 }
1569
1570 /// A handler removed while a sample is already being dispatched must stay
1571 /// alive for that call — the snapshot holds an `Arc`, so the removal can
1572 /// never free a handler out from under a running callback.
1573 #[test]
1574 fn test_snapshot_keeps_a_concurrently_removed_handler_alive() {
1575 let ctx = StreamContext::new(None);
1576 let calls = Arc::new(AtomicUsize::new(0));
1577
1578 {
1579 let counter = calls.clone();
1580 unsafe { &*ctx }
1581 .handlers
1582 .write()
1583 .unwrap_or_else(std::sync::PoisonError::into_inner)
1584 .push(HandlerEntry {
1585 id: 1,
1586 of_type: SCStreamOutputType::Audio,
1587 handler: Arc::new(
1588 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1589 counter.fetch_add(1, Ordering::Relaxed);
1590 std::mem::forget(buf);
1591 },
1592 ),
1593 });
1594 }
1595
1596 let snapshot = unsafe { &*ctx }.handler_snapshot(SCStreamOutputType::Audio);
1597
1598 // Remove the handler *after* the snapshot was taken, mirroring a
1599 // `remove_output_handler` racing an in-flight callback.
1600 unsafe { &*ctx }
1601 .handlers
1602 .write()
1603 .unwrap_or_else(std::sync::PoisonError::into_inner)
1604 .clear();
1605
1606 for handler in &snapshot {
1607 let buffer = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1608 handler.did_output_sample_buffer(buffer, SCStreamOutputType::Audio);
1609 }
1610
1611 assert_eq!(calls.load(Ordering::Relaxed), 1);
1612 // The next dispatch sees the removal.
1613 assert!(unsafe { &*ctx }
1614 .handler_snapshot(SCStreamOutputType::Audio)
1615 .is_empty());
1616
1617 unsafe { StreamContext::release(ctx) };
1618 }
1619
1620 /// The delegate snapshot must clone the `Arc` out rather than dispatch
1621 /// under the lock, so a delegate can register handlers (or otherwise
1622 /// re-enter the stream) from its own callback.
1623 #[test]
1624 fn test_delegate_snapshot_runs_user_code_unlocked() {
1625 struct Counting(Arc<AtomicUsize>);
1626 impl SCStreamDelegateTrait for Counting {
1627 fn stream_did_become_active(&self) {
1628 self.0.fetch_add(1, Ordering::Relaxed);
1629 }
1630 }
1631
1632 let calls = Arc::new(AtomicUsize::new(0));
1633 let ctx = StreamContext::new(Some(Arc::new(Counting(calls.clone()))));
1634
1635 let delegate = unsafe { &*ctx }
1636 .delegate_snapshot()
1637 .expect("delegate should be present");
1638 // The lock is free while user code runs.
1639 assert!(unsafe { &*ctx }.delegate.try_write().is_ok());
1640 delegate.stream_did_become_active();
1641
1642 assert_eq!(calls.load(Ordering::Relaxed), 1);
1643
1644 unsafe { StreamContext::release(ctx) };
1645 }
1646
1647 /// Regression test: the four non-error `SCStreamDelegate` callbacks used
1648 /// to be dead code — the bridge never had a trampoline to reach them.
1649 /// This pins the event-code mapping shared with `Stream.swift`.
1650 #[test]
1651 fn test_delegate_event_callback_routes_each_event_code() {
1652 #[derive(Default)]
1653 struct Recorder {
1654 events: std::sync::Mutex<Vec<&'static str>>,
1655 }
1656 impl Recorder {
1657 fn record(&self, what: &'static str) {
1658 self.events
1659 .lock()
1660 .unwrap_or_else(std::sync::PoisonError::into_inner)
1661 .push(what);
1662 }
1663 }
1664 impl SCStreamDelegateTrait for Arc<Recorder> {
1665 fn stream_did_become_active(&self) {
1666 self.record("active");
1667 }
1668 fn stream_did_become_inactive(&self) {
1669 self.record("inactive");
1670 }
1671 fn output_video_effect_did_start_for_stream(&self) {
1672 self.record("effect_start");
1673 }
1674 fn output_video_effect_did_stop_for_stream(&self) {
1675 self.record("effect_stop");
1676 }
1677 }
1678
1679 let recorder = Arc::new(Recorder::default());
1680 let ctx = StreamContext::new(Some(Arc::new(Arc::clone(&recorder))));
1681
1682 for event in 0..4 {
1683 delegate_event_callback(ctx.cast::<c_void>(), event);
1684 }
1685 // Unknown codes are logged, not dispatched, and must not panic.
1686 delegate_event_callback(ctx.cast::<c_void>(), 99);
1687 // A null context is ignored rather than dereferenced.
1688 delegate_event_callback(std::ptr::null_mut(), 0);
1689
1690 assert_eq!(
1691 *recorder
1692 .events
1693 .lock()
1694 .unwrap_or_else(std::sync::PoisonError::into_inner),
1695 vec!["active", "inactive", "effect_start", "effect_stop"]
1696 );
1697
1698 unsafe { StreamContext::release(ctx) };
1699 }
1700
1701 /// A stream with no delegate must swallow lifecycle events instead of
1702 /// dereferencing a missing one.
1703 #[test]
1704 fn test_delegate_event_callback_without_a_delegate_is_a_noop() {
1705 let ctx = StreamContext::new(None);
1706 for event in 0..4 {
1707 delegate_event_callback(ctx.cast::<c_void>(), event);
1708 }
1709 unsafe { StreamContext::release(ctx) };
1710 }
1711
1712 #[test]
1713 fn test_null_native_stream_is_rejected_and_releases_its_context() {
1714 let ctx = StreamContext::new(None);
1715 unsafe { StreamContext::retain(ctx) };
1716
1717 let result = unsafe { SCStream::adopt(std::ptr::null(), ctx) };
1718
1719 assert!(matches!(result, Err(SCError::NullPointer(_))));
1720 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 1);
1721 unsafe { StreamContext::release(ctx) };
1722 }
1723
1724 #[test]
1725 fn test_stream_identity_rejects_null() {
1726 assert!(StreamIdentity::from_ptr(std::ptr::null()).is_none());
1727 let value = 0x1000_usize as *const c_void;
1728 assert_eq!(
1729 StreamIdentity::from_ptr(value),
1730 NonZeroUsize::new(0x1000).map(StreamIdentity)
1731 );
1732 }
1733
1734 #[test]
1735 fn test_claim_start_skips_the_native_call_while_capturing() {
1736 let capturing = AtomicBool::new(false);
1737 let unconfirmed = AtomicBool::new(false);
1738
1739 assert!(claim_start(&capturing, &unconfirmed));
1740 assert!(!claim_start(&capturing, &unconfirmed));
1741 }
1742
1743 /// A start whose completion timed out left `capturing` latched without a
1744 /// confirmed outcome, so the retry must reissue rather than report the
1745 /// success nobody observed.
1746 #[test]
1747 fn test_claim_start_reissues_after_an_unconfirmed_start() {
1748 let capturing = AtomicBool::new(true);
1749 let unconfirmed = AtomicBool::new(true);
1750
1751 assert!(claim_start(&capturing, &unconfirmed));
1752 assert!(!claim_start(&capturing, &unconfirmed));
1753 }
1754
1755 /// The flag must not outlive the retry that consumed it, or a later start
1756 /// on a live stream would reissue against `ScreenCaptureKit`.
1757 #[test]
1758 fn test_claim_start_clears_the_flag_even_when_idle() {
1759 let capturing = AtomicBool::new(false);
1760 let unconfirmed = AtomicBool::new(true);
1761
1762 assert!(claim_start(&capturing, &unconfirmed));
1763 assert!(!unconfirmed.load(Ordering::Acquire));
1764 assert!(!claim_start(&capturing, &unconfirmed));
1765 }
1766}