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 let mut count = context.recording_outputs.load(Ordering::Acquire);
1052 while count > 0 {
1053 match context.recording_outputs.compare_exchange_weak(
1054 count,
1055 count - 1,
1056 Ordering::AcqRel,
1057 Ordering::Acquire,
1058 ) {
1059 Ok(_) => break,
1060 Err(current) => count = current,
1061 }
1062 }
1063 }
1064 outcome.map_err(SCError::StreamError)?;
1065 Ok(())
1066 }
1067
1068 /// Returns the raw pointer to the underlying Swift `SCStream` instance.
1069 #[allow(dead_code)]
1070 pub(crate) fn as_ptr(&self) -> *const c_void {
1071 self.ptr
1072 }
1073
1074 /// Return a stable, non-owning identity for this stream.
1075 #[must_use]
1076 pub fn identity(&self) -> StreamIdentity {
1077 self.identity
1078 }
1079
1080 #[cfg(feature = "async")]
1081 pub(crate) fn capture_state(&self) -> Arc<AtomicBool> {
1082 Arc::clone(&unsafe { &*self.context }.capturing)
1083 }
1084
1085 #[cfg(feature = "async")]
1086 pub(crate) fn start_unconfirmed_state(&self) -> Arc<AtomicBool> {
1087 Arc::clone(&unsafe { &*self.context }.start_unconfirmed)
1088 }
1089}
1090
1091/// Claims the right to issue a native `startCapture`.
1092///
1093/// Returns `false` when the stream is already capturing and the previous start
1094/// was confirmed, so the caller can report success without a redundant FFI
1095/// call. `start_unconfirmed` is always cleared, since a flag left set past a
1096/// successful start would later reissue a start on a live stream.
1097pub(crate) fn claim_start(capturing: &AtomicBool, start_unconfirmed: &AtomicBool) -> bool {
1098 let was_capturing = capturing.swap(true, Ordering::AcqRel);
1099 let unconfirmed = start_unconfirmed.swap(false, Ordering::AcqRel);
1100 !was_capturing || unconfirmed
1101}
1102
1103impl Drop for SCStream {
1104 // Safety / teardown ordering:
1105 //
1106 // `sc_stream_release` drops this handle's claim on the Swift-side
1107 // `StreamState` and releases the `SCStream`. The `StreamState` — and with
1108 // it the stream-output delegate — is discarded only when the *last* handle
1109 // (this stream plus every clone) is gone, so dropping one clone never
1110 // detaches the callbacks of the survivors. We release the `StreamContext`
1111 // afterwards, so the ordering here is release-stream-then-release-context.
1112 //
1113 // In-flight callbacks are safe even though Apple's stop is asynchronous: the
1114 // Swift `StreamDelegateWrapper` and `StreamOutputHandler` objects each hold
1115 // their own +1 reference on the `StreamContext` (taken in `init` via
1116 // `context_retain_cb`, dropped in `deinit` via `context_release_cb`). Each
1117 // callback runs as a method on one of those objects, and ARC keeps that
1118 // object (`self`) alive for the duration of the call, so its context
1119 // reference is also held for the duration of the call. Therefore a callback
1120 // already in flight can never observe a freed context: the final
1121 // `Box::from_raw` only happens once every holder — this Rust `SCStream` and
1122 // both Swift bridge objects — has released its reference.
1123 //
1124 // Refcount accounting: `StreamContext::new` starts at 1; the Swift
1125 // `createStream` adds +1 per bridge object (delegate + output handler) = 3;
1126 // each Rust clone adds +1; each `drop` removes -1; each bridge object's
1127 // `deinit` removes -1, and the context is freed when the total reaches 0.
1128 fn drop(&mut self) {
1129 unsafe { ffi::sc_stream_release(self.ptr) };
1130 unsafe { StreamContext::release(self.context) };
1131 }
1132}
1133
1134impl Clone for SCStream {
1135 /// Clone the stream reference.
1136 ///
1137 /// Cloning an `SCStream` creates a new reference to the same underlying
1138 /// Swift `SCStream` object. The cloned stream shares the same handlers
1139 /// as the original — they receive frames from the same capture session.
1140 ///
1141 /// Both the original and cloned stream share the same capture state, so:
1142 /// - Starting capture on one affects both
1143 /// - Stopping capture on one affects both
1144 /// - Configuration updates affect both
1145 /// - Handlers receive the same frames
1146 /// - Dropping one clone leaves the others fully functional; capture and
1147 /// delegate callbacks stop only once the last clone is dropped
1148 ///
1149 /// # Examples
1150 ///
1151 /// ```rust,no_run
1152 /// use screencapturekit::prelude::*;
1153 ///
1154 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1155 /// # let content = SCShareableContent::get()?;
1156 /// # let display = &content.displays()[0];
1157 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
1158 /// # let config = SCStreamConfiguration::default();
1159 /// let mut stream = SCStream::new(&filter, &config)?;
1160 /// stream.add_output_handler(|_, _| println!("Handler 1"), SCStreamOutputType::Screen)?;
1161 ///
1162 /// // Clone shares the same handlers
1163 /// let stream2 = stream.clone();
1164 /// // Both stream and stream2 will receive frames via Handler 1
1165 /// # Ok(())
1166 /// # }
1167 /// ```
1168 fn clone(&self) -> Self {
1169 unsafe { StreamContext::retain(self.context) };
1170
1171 Self {
1172 ptr: unsafe { crate::ffi::sc_stream_retain(self.ptr) },
1173 identity: self.identity,
1174 context: self.context,
1175 }
1176 }
1177}
1178
1179impl fmt::Debug for SCStream {
1180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1181 f.debug_struct("SCStream")
1182 .field("ptr", &self.ptr)
1183 .finish_non_exhaustive()
1184 }
1185}
1186
1187impl fmt::Display for SCStream {
1188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1189 write!(f, "SCStream")
1190 }
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196 use std::sync::atomic::AtomicUsize;
1197 use std::sync::Arc;
1198
1199 /// Regression test for #135: multiple concurrent streams must not leak
1200 /// samples across each other.
1201 ///
1202 /// Creates two independent `StreamContexts` with separate handlers and
1203 /// directly invokes each context's handlers. Verifies that each handler
1204 /// only receives calls routed through its own context — not from the
1205 /// other context. With the old global `HANDLER_REGISTRY`, both handlers
1206 /// would have been called for every callback regardless of context.
1207 #[test]
1208 fn test_per_stream_callback_isolation() {
1209 let count_a = Arc::new(AtomicUsize::new(0));
1210 let count_b = Arc::new(AtomicUsize::new(0));
1211
1212 // Create two independent contexts (simulates two SCStream instances)
1213 let ctx_a = StreamContext::new(None);
1214 let ctx_b = StreamContext::new(None);
1215
1216 // Register an audio handler on context A
1217 {
1218 let counter = count_a.clone();
1219 let mut handlers = unsafe { &*ctx_a }
1220 .handlers
1221 .write()
1222 .unwrap_or_else(std::sync::PoisonError::into_inner);
1223 handlers.push(HandlerEntry {
1224 id: 1,
1225 of_type: SCStreamOutputType::Audio,
1226 handler: Arc::new(
1227 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1228 counter.fetch_add(1, Ordering::Relaxed);
1229 // Prevent Drop from calling cm_sample_buffer_release on our fake pointer
1230 std::mem::forget(buf);
1231 },
1232 ),
1233 });
1234 }
1235
1236 // Register an audio handler on context B
1237 {
1238 let counter = count_b.clone();
1239 let mut handlers = unsafe { &*ctx_b }
1240 .handlers
1241 .write()
1242 .unwrap_or_else(std::sync::PoisonError::into_inner);
1243 handlers.push(HandlerEntry {
1244 id: 2,
1245 of_type: SCStreamOutputType::Audio,
1246 handler: Arc::new(
1247 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1248 counter.fetch_add(1, Ordering::Relaxed);
1249 std::mem::forget(buf);
1250 },
1251 ),
1252 });
1253 }
1254
1255 // Simulate 5 audio callbacks on context A by directly calling matching handlers
1256 for _ in 0..5 {
1257 let handlers = unsafe { &*ctx_a }
1258 .handlers
1259 .write()
1260 .unwrap_or_else(std::sync::PoisonError::into_inner);
1261 for entry in handlers
1262 .iter()
1263 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1264 {
1265 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1266 entry
1267 .handler
1268 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1269 }
1270 }
1271
1272 // Simulate 3 audio callbacks on context B
1273 for _ in 0..3 {
1274 let handlers = unsafe { &*ctx_b }
1275 .handlers
1276 .write()
1277 .unwrap_or_else(std::sync::PoisonError::into_inner);
1278 for entry in handlers
1279 .iter()
1280 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1281 {
1282 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1283 entry
1284 .handler
1285 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1286 }
1287 }
1288
1289 // Handler A must have received exactly 5 — not 8
1290 assert_eq!(
1291 count_a.load(Ordering::Relaxed),
1292 5,
1293 "handler A received callbacks meant for B (cross-stream leak)"
1294 );
1295 // Handler B must have received exactly 3 — not 8
1296 assert_eq!(
1297 count_b.load(Ordering::Relaxed),
1298 3,
1299 "handler B received callbacks meant for A (cross-stream leak)"
1300 );
1301
1302 unsafe {
1303 StreamContext::release(ctx_a);
1304 StreamContext::release(ctx_b);
1305 }
1306 }
1307
1308 /// Verify that handlers are filtered by output type within a single context.
1309 #[test]
1310 fn test_handler_output_type_filtering() {
1311 let screen_count = Arc::new(AtomicUsize::new(0));
1312 let audio_count = Arc::new(AtomicUsize::new(0));
1313
1314 let ctx = StreamContext::new(None);
1315
1316 {
1317 let counter = screen_count.clone();
1318 let mut handlers = unsafe { &*ctx }
1319 .handlers
1320 .write()
1321 .unwrap_or_else(std::sync::PoisonError::into_inner);
1322 handlers.push(HandlerEntry {
1323 id: 1,
1324 of_type: SCStreamOutputType::Screen,
1325 handler: Arc::new(
1326 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1327 counter.fetch_add(1, Ordering::Relaxed);
1328 std::mem::forget(buf);
1329 },
1330 ),
1331 });
1332 }
1333 {
1334 let counter = audio_count.clone();
1335 let mut handlers = unsafe { &*ctx }
1336 .handlers
1337 .write()
1338 .unwrap_or_else(std::sync::PoisonError::into_inner);
1339 handlers.push(HandlerEntry {
1340 id: 2,
1341 of_type: SCStreamOutputType::Audio,
1342 handler: Arc::new(
1343 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1344 counter.fetch_add(1, Ordering::Relaxed);
1345 std::mem::forget(buf);
1346 },
1347 ),
1348 });
1349 }
1350
1351 // Send 4 screen callbacks
1352 for _ in 0..4 {
1353 let handlers = unsafe { &*ctx }
1354 .handlers
1355 .write()
1356 .unwrap_or_else(std::sync::PoisonError::into_inner);
1357 for entry in handlers
1358 .iter()
1359 .filter(|e| e.of_type == SCStreamOutputType::Screen)
1360 {
1361 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1362 entry
1363 .handler
1364 .did_output_sample_buffer(buf, SCStreamOutputType::Screen);
1365 }
1366 }
1367
1368 // Send 2 audio callbacks
1369 for _ in 0..2 {
1370 let handlers = unsafe { &*ctx }
1371 .handlers
1372 .write()
1373 .unwrap_or_else(std::sync::PoisonError::into_inner);
1374 for entry in handlers
1375 .iter()
1376 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1377 {
1378 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1379 entry
1380 .handler
1381 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1382 }
1383 }
1384
1385 assert_eq!(screen_count.load(Ordering::Relaxed), 4);
1386 assert_eq!(audio_count.load(Ordering::Relaxed), 2);
1387
1388 unsafe { StreamContext::release(ctx) };
1389 }
1390
1391 /// Verify that `StreamContext` ref counting works correctly.
1392 #[test]
1393 fn test_stream_context_ref_counting() {
1394 let ctx = StreamContext::new(None);
1395
1396 // Initial ref count is 1
1397 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 1);
1398
1399 // Retain bumps to 2
1400 unsafe { StreamContext::retain(ctx) };
1401 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 2);
1402
1403 // First release drops to 1 — context still alive
1404 unsafe { StreamContext::release(ctx) };
1405 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 1);
1406
1407 // Second release drops to 0 — context freed (no crash = success)
1408 unsafe { StreamContext::release(ctx) };
1409 }
1410
1411 /// Regression test: a panic in a user-supplied output handler must NOT
1412 /// poison the handlers `RwLock`, must NOT propagate across the C ABI,
1413 /// and must NOT prevent subsequent callbacks from being dispatched.
1414 ///
1415 /// This validates the C1+C2 fix from the deep review: `catch_unwind`
1416 /// around user dispatch and `RwLock` poisoning recovery via
1417 /// `unwrap_or_else(PoisonError::into_inner)` together prevent one
1418 /// panicking handler from permanently breaking the stream.
1419 #[test]
1420 fn test_panic_in_handler_is_isolated() {
1421 // Set a no-op panic hook so our intentional panic doesn't spam the
1422 // test output. We restore it at the end of the test.
1423 let original_hook = std::panic::take_hook();
1424 std::panic::set_hook(Box::new(|_| {}));
1425
1426 let panicked_count = Arc::new(AtomicUsize::new(0));
1427 let normal_count = Arc::new(AtomicUsize::new(0));
1428
1429 let ctx = StreamContext::new(None);
1430
1431 // Handler 1: always panics
1432 {
1433 let counter = panicked_count.clone();
1434 let mut handlers = unsafe { &*ctx }
1435 .handlers
1436 .write()
1437 .unwrap_or_else(std::sync::PoisonError::into_inner);
1438 handlers.push(HandlerEntry {
1439 id: 1,
1440 of_type: SCStreamOutputType::Audio,
1441 handler: Arc::new(
1442 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1443 counter.fetch_add(1, Ordering::Relaxed);
1444 std::mem::forget(buf);
1445 panic!("intentional test panic");
1446 },
1447 ),
1448 });
1449 }
1450
1451 // Handler 2: well-behaved, registered AFTER the panicker
1452 {
1453 let counter = normal_count.clone();
1454 let mut handlers = unsafe { &*ctx }
1455 .handlers
1456 .write()
1457 .unwrap_or_else(std::sync::PoisonError::into_inner);
1458 handlers.push(HandlerEntry {
1459 id: 2,
1460 of_type: SCStreamOutputType::Audio,
1461 handler: Arc::new(
1462 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1463 counter.fetch_add(1, Ordering::Relaxed);
1464 std::mem::forget(buf);
1465 },
1466 ),
1467 });
1468 }
1469
1470 // Simulate 5 callbacks. Each iteration, the panicker fires (and
1471 // panics), then the well-behaved handler must still fire on the
1472 // SAME callback because both handlers match the output type. We
1473 // simulate the dispatch path without going through the C callback
1474 // (which would require a real CMSampleBuffer); the key behaviour
1475 // we're verifying is that the lock isn't poisoned and that the
1476 // catch_unwind boundary contains the panic.
1477 for _ in 0..5 {
1478 let handlers = unsafe { &*ctx }
1479 .handlers
1480 .read()
1481 .unwrap_or_else(std::sync::PoisonError::into_inner);
1482 for entry in handlers
1483 .iter()
1484 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1485 {
1486 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1487 catch_user_panic("test handler", || {
1488 entry
1489 .handler
1490 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1491 });
1492 }
1493 }
1494
1495 // Both handlers fired 5 times each — the panicker did not stop the
1496 // dispatch loop or poison the lock for subsequent reads.
1497 assert_eq!(
1498 panicked_count.load(Ordering::Relaxed),
1499 5,
1500 "panicking handler stopped firing after first panic"
1501 );
1502 assert_eq!(
1503 normal_count.load(Ordering::Relaxed),
1504 5,
1505 "well-behaved handler stopped firing after panicker poisoned state"
1506 );
1507
1508 // Lock is still acquirable (would otherwise be poisoned).
1509 drop(
1510 unsafe { &*ctx }
1511 .handlers
1512 .write()
1513 .unwrap_or_else(std::sync::PoisonError::into_inner),
1514 );
1515
1516 unsafe { StreamContext::release(ctx) };
1517
1518 // Restore the original panic hook so other tests behave normally.
1519 std::panic::set_hook(original_hook);
1520 }
1521
1522 /// Regression test: dispatching a sample must not hold the handlers lock,
1523 /// so a handler is free to mutate the handler set from inside the
1524 /// callback. Under the previous read-lock-across-dispatch design this
1525 /// deadlocked on the `RwLock` upgrade.
1526 #[test]
1527 fn test_handler_may_take_the_write_lock_from_inside_a_callback() {
1528 struct Reentrant(*mut StreamContext);
1529 // SAFETY: the pointer is only used to take the same locks the real
1530 // callback path takes; the context outlives the handler in this test.
1531 unsafe impl Send for Reentrant {}
1532 unsafe impl Sync for Reentrant {}
1533
1534 impl SCStreamOutputTrait for Reentrant {
1535 fn did_output_sample_buffer(
1536 &self,
1537 buffer: crate::cm::CMSampleBuffer,
1538 _of_type: SCStreamOutputType,
1539 ) {
1540 std::mem::forget(buffer);
1541 // Would deadlock if the dispatch path still held a read lock.
1542 let mut handlers = unsafe { &*self.0 }
1543 .handlers
1544 .write()
1545 .unwrap_or_else(std::sync::PoisonError::into_inner);
1546 handlers.retain(|e| e.id != 1);
1547 }
1548 }
1549
1550 let ctx = StreamContext::new(None);
1551 unsafe { &*ctx }
1552 .handlers
1553 .write()
1554 .unwrap_or_else(std::sync::PoisonError::into_inner)
1555 .push(HandlerEntry {
1556 id: 1,
1557 of_type: SCStreamOutputType::Screen,
1558 handler: Arc::new(Reentrant(ctx)),
1559 });
1560
1561 for handler in unsafe { &*ctx }.handler_snapshot(SCStreamOutputType::Screen) {
1562 let buffer = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1563 handler.did_output_sample_buffer(buffer, SCStreamOutputType::Screen);
1564 }
1565
1566 assert!(
1567 unsafe { &*ctx }
1568 .handler_snapshot(SCStreamOutputType::Screen)
1569 .is_empty(),
1570 "handler failed to remove itself from inside its own callback"
1571 );
1572
1573 unsafe { StreamContext::release(ctx) };
1574 }
1575
1576 /// A handler removed while a sample is already being dispatched must stay
1577 /// alive for that call — the snapshot holds an `Arc`, so the removal can
1578 /// never free a handler out from under a running callback.
1579 #[test]
1580 fn test_snapshot_keeps_a_concurrently_removed_handler_alive() {
1581 let ctx = StreamContext::new(None);
1582 let calls = Arc::new(AtomicUsize::new(0));
1583
1584 {
1585 let counter = calls.clone();
1586 unsafe { &*ctx }
1587 .handlers
1588 .write()
1589 .unwrap_or_else(std::sync::PoisonError::into_inner)
1590 .push(HandlerEntry {
1591 id: 1,
1592 of_type: SCStreamOutputType::Audio,
1593 handler: Arc::new(
1594 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1595 counter.fetch_add(1, Ordering::Relaxed);
1596 std::mem::forget(buf);
1597 },
1598 ),
1599 });
1600 }
1601
1602 let snapshot = unsafe { &*ctx }.handler_snapshot(SCStreamOutputType::Audio);
1603
1604 // Remove the handler *after* the snapshot was taken, mirroring a
1605 // `remove_output_handler` racing an in-flight callback.
1606 unsafe { &*ctx }
1607 .handlers
1608 .write()
1609 .unwrap_or_else(std::sync::PoisonError::into_inner)
1610 .clear();
1611
1612 for handler in &snapshot {
1613 let buffer = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1614 handler.did_output_sample_buffer(buffer, SCStreamOutputType::Audio);
1615 }
1616
1617 assert_eq!(calls.load(Ordering::Relaxed), 1);
1618 // The next dispatch sees the removal.
1619 assert!(unsafe { &*ctx }
1620 .handler_snapshot(SCStreamOutputType::Audio)
1621 .is_empty());
1622
1623 unsafe { StreamContext::release(ctx) };
1624 }
1625
1626 /// The delegate snapshot must clone the `Arc` out rather than dispatch
1627 /// under the lock, so a delegate can register handlers (or otherwise
1628 /// re-enter the stream) from its own callback.
1629 #[test]
1630 fn test_delegate_snapshot_runs_user_code_unlocked() {
1631 struct Counting(Arc<AtomicUsize>);
1632 impl SCStreamDelegateTrait for Counting {
1633 fn stream_did_become_active(&self) {
1634 self.0.fetch_add(1, Ordering::Relaxed);
1635 }
1636 }
1637
1638 let calls = Arc::new(AtomicUsize::new(0));
1639 let ctx = StreamContext::new(Some(Arc::new(Counting(calls.clone()))));
1640
1641 let delegate = unsafe { &*ctx }
1642 .delegate_snapshot()
1643 .expect("delegate should be present");
1644 // The lock is free while user code runs.
1645 assert!(unsafe { &*ctx }.delegate.try_write().is_ok());
1646 delegate.stream_did_become_active();
1647
1648 assert_eq!(calls.load(Ordering::Relaxed), 1);
1649
1650 unsafe { StreamContext::release(ctx) };
1651 }
1652
1653 /// Regression test: the four non-error `SCStreamDelegate` callbacks used
1654 /// to be dead code — the bridge never had a trampoline to reach them.
1655 /// This pins the event-code mapping shared with `Stream.swift`.
1656 #[test]
1657 fn test_delegate_event_callback_routes_each_event_code() {
1658 #[derive(Default)]
1659 struct Recorder {
1660 events: std::sync::Mutex<Vec<&'static str>>,
1661 }
1662 impl Recorder {
1663 fn record(&self, what: &'static str) {
1664 self.events
1665 .lock()
1666 .unwrap_or_else(std::sync::PoisonError::into_inner)
1667 .push(what);
1668 }
1669 }
1670 impl SCStreamDelegateTrait for Arc<Recorder> {
1671 fn stream_did_become_active(&self) {
1672 self.record("active");
1673 }
1674 fn stream_did_become_inactive(&self) {
1675 self.record("inactive");
1676 }
1677 fn output_video_effect_did_start_for_stream(&self) {
1678 self.record("effect_start");
1679 }
1680 fn output_video_effect_did_stop_for_stream(&self) {
1681 self.record("effect_stop");
1682 }
1683 }
1684
1685 let recorder = Arc::new(Recorder::default());
1686 let ctx = StreamContext::new(Some(Arc::new(Arc::clone(&recorder))));
1687
1688 for event in 0..4 {
1689 delegate_event_callback(ctx.cast::<c_void>(), event);
1690 }
1691 // Unknown codes are logged, not dispatched, and must not panic.
1692 delegate_event_callback(ctx.cast::<c_void>(), 99);
1693 // A null context is ignored rather than dereferenced.
1694 delegate_event_callback(std::ptr::null_mut(), 0);
1695
1696 assert_eq!(
1697 *recorder
1698 .events
1699 .lock()
1700 .unwrap_or_else(std::sync::PoisonError::into_inner),
1701 vec!["active", "inactive", "effect_start", "effect_stop"]
1702 );
1703
1704 unsafe { StreamContext::release(ctx) };
1705 }
1706
1707 /// A stream with no delegate must swallow lifecycle events instead of
1708 /// dereferencing a missing one.
1709 #[test]
1710 fn test_delegate_event_callback_without_a_delegate_is_a_noop() {
1711 let ctx = StreamContext::new(None);
1712 for event in 0..4 {
1713 delegate_event_callback(ctx.cast::<c_void>(), event);
1714 }
1715 unsafe { StreamContext::release(ctx) };
1716 }
1717
1718 #[test]
1719 fn test_null_native_stream_is_rejected_and_releases_its_context() {
1720 let ctx = StreamContext::new(None);
1721 unsafe { StreamContext::retain(ctx) };
1722
1723 let result = unsafe { SCStream::adopt(std::ptr::null(), ctx) };
1724
1725 assert!(matches!(result, Err(SCError::NullPointer(_))));
1726 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 1);
1727 unsafe { StreamContext::release(ctx) };
1728 }
1729
1730 #[test]
1731 fn test_stream_identity_rejects_null() {
1732 assert!(StreamIdentity::from_ptr(std::ptr::null()).is_none());
1733 let value = 0x1000_usize as *const c_void;
1734 assert_eq!(
1735 StreamIdentity::from_ptr(value),
1736 NonZeroUsize::new(0x1000).map(StreamIdentity)
1737 );
1738 }
1739
1740 #[test]
1741 fn test_claim_start_skips_the_native_call_while_capturing() {
1742 let capturing = AtomicBool::new(false);
1743 let unconfirmed = AtomicBool::new(false);
1744
1745 assert!(claim_start(&capturing, &unconfirmed));
1746 assert!(!claim_start(&capturing, &unconfirmed));
1747 }
1748
1749 /// A start whose completion timed out left `capturing` latched without a
1750 /// confirmed outcome, so the retry must reissue rather than report the
1751 /// success nobody observed.
1752 #[test]
1753 fn test_claim_start_reissues_after_an_unconfirmed_start() {
1754 let capturing = AtomicBool::new(true);
1755 let unconfirmed = AtomicBool::new(true);
1756
1757 assert!(claim_start(&capturing, &unconfirmed));
1758 assert!(!claim_start(&capturing, &unconfirmed));
1759 }
1760
1761 /// The flag must not outlive the retry that consumed it, or a later start
1762 /// on a live stream would reissue against `ScreenCaptureKit`.
1763 #[test]
1764 fn test_claim_start_clears_the_flag_even_when_idle() {
1765 let capturing = AtomicBool::new(false);
1766 let unconfirmed = AtomicBool::new(true);
1767
1768 assert!(claim_start(&capturing, &unconfirmed));
1769 assert!(!unconfirmed.load(Ordering::Acquire));
1770 assert!(!claim_start(&capturing, &unconfirmed));
1771 }
1772}