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::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::RwLock;
14
15use crate::error::SCError;
16use crate::stream::delegate_trait::SCStreamDelegateTrait;
17use crate::utils::completion::UnitCompletion;
18use crate::utils::panic_safe::catch_user_panic;
19use crate::{
20 dispatch_queue::DispatchQueue,
21 ffi,
22 stream::{
23 configuration::SCStreamConfiguration, content_filter::SCContentFilter,
24 output_trait::SCStreamOutputTrait, output_type::SCStreamOutputType,
25 },
26};
27
28/// Per-stream handler entry.
29struct HandlerEntry {
30 id: usize,
31 of_type: SCStreamOutputType,
32 handler: Box<dyn SCStreamOutputTrait>,
33}
34
35/// Per-stream context holding output handlers and an optional delegate.
36///
37/// Allocated on the heap via `Box::into_raw` and passed through FFI as an
38/// opaque context pointer. Callbacks cast it back to `&StreamContext` for
39/// direct, O(1) access to the owning stream's state.
40///
41/// `handlers` and `delegate` are stored behind `RwLock`s rather than
42/// `Mutex`es so concurrent callbacks from `ScreenCaptureKit`'s independent
43/// dispatch queues (e.g. screen + audio) can dispatch in parallel. Slow
44/// user handlers no longer serialise across output types.
45struct StreamContext {
46 handlers: RwLock<Vec<HandlerEntry>>,
47 delegate: RwLock<Option<Box<dyn SCStreamDelegateTrait>>>,
48 ref_count: AtomicUsize,
49}
50
51impl StreamContext {
52 fn new() -> *mut Self {
53 let ctx = Box::new(Self {
54 handlers: RwLock::new(Vec::new()),
55 delegate: RwLock::new(None),
56 ref_count: AtomicUsize::new(1),
57 });
58 Box::into_raw(ctx)
59 }
60
61 fn new_with_delegate(delegate: Box<dyn SCStreamDelegateTrait>) -> *mut Self {
62 let ctx = Box::new(Self {
63 handlers: RwLock::new(Vec::new()),
64 delegate: RwLock::new(Some(delegate)),
65 ref_count: AtomicUsize::new(1),
66 });
67 Box::into_raw(ctx)
68 }
69
70 /// Increment the reference count.
71 ///
72 /// # Safety
73 ///
74 /// `ptr` must point to a valid, live `StreamContext`.
75 unsafe fn retain(ptr: *mut Self) {
76 unsafe { &*ptr }.ref_count.fetch_add(1, Ordering::Relaxed);
77 }
78
79 /// Decrement the reference count, freeing the context if it reaches zero.
80 ///
81 /// # Safety
82 ///
83 /// `ptr` must point to a valid, live `StreamContext`. After this call,
84 /// `ptr` must not be used if the context was freed.
85 unsafe fn release(ptr: *mut Self) {
86 if ptr.is_null() {
87 return;
88 }
89 let prev = unsafe { &*ptr }.ref_count.fetch_sub(1, Ordering::Release);
90 if prev == 1 {
91 // The Acquire fence is required (NOT redundant — it pairs with
92 // the Release stores from other threads' `fetch_sub` calls
93 // and any other writes to `*ptr` they performed). It guarantees
94 // that the freeing thread sees all happened-before writes from
95 // every other thread that previously held a reference. This is
96 // the canonical Arc-style refcount drop pattern (see
97 // `std::sync::Arc::drop`); removing the fence is unsound on
98 // weakly-ordered architectures (e.g. AArch64).
99 std::sync::atomic::fence(Ordering::Acquire);
100 drop(unsafe { Box::from_raw(ptr) });
101 }
102 }
103}
104
105/// Compile-time assertion: `StreamContext` is `Send + Sync`.
106///
107/// `SCStream` carries `unsafe impl Send + Sync` (lines below); that impl is
108/// only sound if the underlying `StreamContext` is itself `Send + Sync`.
109/// Without this static check, a future refactor that adds a `!Send` or
110/// `!Sync` field (or removes the `Send`/`Sync` bound from a trait it holds
111/// in `Box<dyn …>`) would silently invalidate the unsafe impl with no
112/// compiler error. This `const _` forces a compile error in that case.
113const _: fn() = || {
114 fn assert_send_sync<T: Send + Sync>() {}
115 assert_send_sync::<StreamContext>();
116};
117
118/// Monotonically increasing handler ID generator (process-wide).
119static NEXT_HANDLER_ID: AtomicUsize = AtomicUsize::new(1);
120
121// C trampoline handed to Swift so the bridge objects (delegate wrapper and
122// output handler) can each take a +1 reference on the `StreamContext` for the
123// duration of their own lifetime. This keeps the context alive while any
124// callback can still be dispatched on it.
125extern "C" fn context_retain_cb(context: *mut c_void) {
126 if !context.is_null() {
127 unsafe { StreamContext::retain(context.cast::<StreamContext>()) };
128 }
129}
130
131// C trampoline handed to Swift, invoked from each bridge object's `deinit` to
132// drop the +1 reference taken in `context_retain_cb`. `StreamContext::release`
133// null-checks internally.
134extern "C" fn context_release_cb(context: *mut c_void) {
135 unsafe { StreamContext::release(context.cast::<StreamContext>()) };
136}
137
138// C callback for stream errors — dispatches to per-stream delegate via context pointer.
139//
140// Safety: this function is called from Swift. A Rust panic unwinding across
141// the C ABI is undefined behavior, so all user-visible code (delegate trait
142// methods) is wrapped in `catch_unwind`. The `delegate` lock is taken with
143// `unwrap_or_else` poisoning recovery so a panic in one callback cannot
144// permanently break the stream by poisoning the lock.
145extern "C" fn delegate_error_callback(context: *mut c_void, error_code: i32, msg: *const i8) {
146 if context.is_null() {
147 return;
148 }
149 // SAFETY: `context` is the +1-retained StreamContext pointer the Swift
150 // bridge stored via context_retain_cb; it outlives this callback.
151 let ctx = unsafe { &*(context.cast::<StreamContext>()) };
152
153 let message = if msg.is_null() {
154 "Unknown error".to_string()
155 } else {
156 // Best-effort: if Swift sent a non-UTF-8 buffer, fall back to a
157 // placeholder rather than panicking.
158 unsafe { CStr::from_ptr(msg) }
159 .to_str()
160 .unwrap_or("Unknown error")
161 .to_string()
162 };
163
164 let error = if error_code != 0 {
165 crate::error::SCStreamErrorCode::from_raw(error_code).map_or_else(
166 || SCError::StreamError(format!("{message} (code: {error_code})")),
167 |code| SCError::SCStreamError {
168 code,
169 message: Some(message.clone()),
170 },
171 )
172 } else {
173 SCError::StreamError(message)
174 };
175
176 // Take a read lock and dispatch under it. Multiple delegate callbacks
177 // (e.g. error + activity) from independent queues can run concurrently.
178 // Recover from poisoning in case a previous callback panicked outside
179 // catch_unwind (defense in depth).
180 let delegate_guard = ctx
181 .delegate
182 .read()
183 .unwrap_or_else(std::sync::PoisonError::into_inner);
184
185 if let Some(ref delegate) = *delegate_guard {
186 // ScreenCaptureKit reports stops only through `stream(_:didStopWithError:)`,
187 // so we dispatch the single canonical `did_stop_with_error` callback.
188 // The deprecated `stream_did_stop` is intentionally NOT invoked here — it
189 // would double-notify for one event. Wrap user code in catch_unwind so a
190 // panic never propagates into Swift.
191 catch_user_panic("delegate.did_stop_with_error", || {
192 delegate.did_stop_with_error(error);
193 });
194 return;
195 }
196
197 drop(delegate_guard);
198 // Fallback to logging if no delegate registered
199 eprintln!("SCStream error: {error}");
200}
201
202// C callback for sample buffers — dispatches to per-stream handlers via context pointer.
203//
204// Safety: this function is called from Swift on a dispatch queue. A Rust
205// panic across the C ABI is UB; every user handler invocation is wrapped in
206// `catch_unwind`. The `handlers` lock is a read lock so independent dispatch
207// queues (screen, audio, microphone) can dispatch in parallel — a slow
208// handler on one queue cannot block callbacks on another. The `passRetained`
209// `CMSampleBuffer` reference Swift hands us is consumed exactly once: each
210// non-final matching handler receives a freshly retained clone, and the
211// final matching handler consumes the original.
212extern "C" fn sample_handler(context: *mut c_void, sample_buffer: *const c_void, output_type: i32) {
213 if context.is_null() {
214 unsafe { crate::cm::ffi::cm_sample_buffer_release(sample_buffer.cast_mut()) };
215 return;
216 }
217 // SAFETY: `context` is the +1-retained StreamContext pointer the Swift
218 // bridge stored via context_retain_cb; it outlives this callback.
219 let ctx = unsafe { &*(context.cast::<StreamContext>()) };
220
221 let output_type_enum = match output_type {
222 0 => SCStreamOutputType::Screen,
223 1 => SCStreamOutputType::Audio,
224 2 => SCStreamOutputType::Microphone,
225 _ => {
226 eprintln!("Unknown output type: {output_type}");
227 unsafe { crate::cm::ffi::cm_sample_buffer_release(sample_buffer.cast_mut()) };
228 return;
229 }
230 };
231
232 // Read lock allows concurrent dispatch from independent dispatch queues.
233 // Recover from poisoning in case a previous panic somehow escaped
234 // catch_unwind (defense in depth).
235 let handlers = ctx
236 .handlers
237 .read()
238 .unwrap_or_else(std::sync::PoisonError::into_inner);
239
240 let mut matching = handlers
241 .iter()
242 .filter(|e| e.of_type == output_type_enum)
243 .peekable();
244
245 if matching.peek().is_none() {
246 // Drop the lock before releasing the buffer, in case the release
247 // path ever takes any locks of its own.
248 drop(handlers);
249 unsafe { crate::cm::ffi::cm_sample_buffer_release(sample_buffer.cast_mut()) };
250 return;
251 }
252
253 while let Some(entry) = matching.next() {
254 // Retain for every handler except the last; the last handler consumes
255 // the original `passRetained` reference Swift gave us. `peek()` after
256 // `next()` reports the next matching entry (or None if `entry` was
257 // the last matching one).
258 let is_last = matching.peek().is_none();
259 if !is_last {
260 unsafe { crate::cm::ffi::cm_sample_buffer_retain(sample_buffer.cast_mut()) };
261 }
262
263 let buffer = unsafe { crate::cm::CMSampleBuffer::from_ptr(sample_buffer.cast_mut()) };
264
265 // Wrap user code in catch_unwind so panics never propagate into Swift.
266 // If the handler panics, `buffer` is dropped on unwind, which calls
267 // `cm_sample_buffer_release` and balances the retain we just did
268 // (or, for the last handler, balances the original `passRetained`).
269 // The retain/release accounting is preserved either way.
270 catch_user_panic("output handler", || {
271 entry
272 .handler
273 .did_output_sample_buffer(buffer, output_type_enum);
274 });
275 }
276}
277
278/// `SCStream` is a lightweight wrapper around the Swift `SCStream` instance.
279/// It provides direct FFI access to `ScreenCaptureKit` functionality.
280///
281/// This is the primary and only implementation of `SCStream` in v1.0+.
282/// All `ScreenCaptureKit` operations go through Swift FFI bindings.
283///
284/// # Examples
285///
286/// ```no_run
287/// use screencapturekit::prelude::*;
288///
289/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
290/// // Get shareable content
291/// let content = SCShareableContent::get()?;
292/// let display = &content.displays()[0];
293///
294/// // Create filter and configuration
295/// let filter = SCContentFilter::create()
296/// .with_display(display)
297/// .with_excluding_windows(&[])
298/// .build();
299/// let config = SCStreamConfiguration::new()
300/// .with_width(1920)
301/// .with_height(1080);
302///
303/// // Create and start stream
304/// let mut stream = SCStream::new(&filter, &config);
305/// stream.start_capture()?;
306///
307/// // ... capture frames ...
308///
309/// stream.stop_capture()?;
310/// # Ok(())
311/// # }
312/// ```
313pub struct SCStream {
314 ptr: *const c_void,
315 /// Per-stream context holding handlers and delegate (ref-counted).
316 context: *mut StreamContext,
317}
318
319unsafe impl Send for SCStream {}
320unsafe impl Sync for SCStream {}
321
322impl SCStream {
323 /// Create a new stream with a content filter and configuration
324 ///
325 /// # Examples
326 ///
327 /// ```no_run
328 /// use screencapturekit::prelude::*;
329 ///
330 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
331 /// let content = SCShareableContent::get()?;
332 /// let display = &content.displays()[0];
333 /// let filter = SCContentFilter::create()
334 /// .with_display(display)
335 /// .with_excluding_windows(&[])
336 /// .build();
337 /// let config = SCStreamConfiguration::new()
338 /// .with_width(1920)
339 /// .with_height(1080);
340 ///
341 /// let stream = SCStream::new(&filter, &config);
342 /// # Ok(())
343 /// # }
344 /// ```
345 pub fn new(filter: &SCContentFilter, configuration: &SCStreamConfiguration) -> Self {
346 let context = StreamContext::new();
347 let context_ptr = context.cast::<c_void>();
348
349 let ptr = unsafe {
350 ffi::sc_stream_create(
351 filter.as_ptr(),
352 configuration.as_ptr(),
353 context_ptr,
354 delegate_error_callback,
355 sample_handler,
356 context_retain_cb,
357 context_release_cb,
358 )
359 };
360
361 Self { ptr, context }
362 }
363
364 /// Create a new stream with a content filter, configuration, and delegate
365 ///
366 /// The delegate receives callbacks for stream lifecycle events. The key
367 /// one is [`did_stop_with_error`](crate::stream::delegate_trait::SCStreamDelegateTrait::did_stop_with_error),
368 /// invoked when `ScreenCaptureKit` stops the stream with an error (e.g. the
369 /// captured window closes or permission is revoked). A *clean* stop you
370 /// requested via [`stop_capture`](Self::stop_capture) is observed through
371 /// that call's return value, not the delegate.
372 ///
373 /// # Examples
374 ///
375 /// ```no_run
376 /// use screencapturekit::prelude::*;
377 /// use screencapturekit::stream::delegate_trait::StreamCallbacks;
378 ///
379 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
380 /// let content = SCShareableContent::get()?;
381 /// let display = &content.displays()[0];
382 /// let filter = SCContentFilter::create()
383 /// .with_display(display)
384 /// .with_excluding_windows(&[])
385 /// .build();
386 /// let config = SCStreamConfiguration::new()
387 /// .with_width(1920)
388 /// .with_height(1080);
389 ///
390 /// let delegate = StreamCallbacks::new()
391 /// .on_error(|e| eprintln!("Stream stopped with error: {}", e));
392 ///
393 /// let stream = SCStream::new_with_delegate(&filter, &config, delegate);
394 /// stream.start_capture()?;
395 /// # Ok(())
396 /// # }
397 /// ```
398 pub fn new_with_delegate(
399 filter: &SCContentFilter,
400 configuration: &SCStreamConfiguration,
401 delegate: impl SCStreamDelegateTrait + 'static,
402 ) -> Self {
403 let context = StreamContext::new_with_delegate(Box::new(delegate));
404 let context_ptr = context.cast::<c_void>();
405
406 let ptr = unsafe {
407 ffi::sc_stream_create(
408 filter.as_ptr(),
409 configuration.as_ptr(),
410 context_ptr,
411 delegate_error_callback,
412 sample_handler,
413 context_retain_cb,
414 context_release_cb,
415 )
416 };
417
418 Self { ptr, context }
419 }
420
421 /// Add an output handler to receive captured frames
422 ///
423 /// # Arguments
424 ///
425 /// * `handler` - The handler to receive callbacks. Can be:
426 /// - A struct implementing [`SCStreamOutputTrait`]
427 /// - A closure `|CMSampleBuffer, SCStreamOutputType| { ... }`
428 /// * `of_type` - The type of output to receive (Screen, Audio, or Microphone)
429 ///
430 /// # Returns
431 ///
432 /// Returns `Some(handler_id)` on success, or `None` if `ScreenCaptureKit`
433 /// rejected the registration (e.g. the output type is not enabled by the
434 /// stream configuration); the failure is also logged to stderr. The handler
435 /// ID can be used with [`remove_output_handler`](Self::remove_output_handler).
436 ///
437 /// # Dispatch queue
438 ///
439 /// The handler is invoked on a dedicated user-interactive serial dispatch
440 /// queue created by the bridge. This intentionally **deviates from
441 /// Apple's `SCStream.addStreamOutput`** API, whose `nil` queue parameter
442 /// means "deliver on the main queue". Main-queue dispatch only works
443 /// when the host process runs a Cocoa runloop, which Rust apps
444 /// generally don't, so the default would otherwise silently drop
445 /// every frame. Use [`add_output_handler_with_queue`](Self::add_output_handler_with_queue)
446 /// and pass an explicit [`DispatchQueue`] (e.g. one wrapping main) if
447 /// you need a different queue — including AppKit/UIKit affinity.
448 ///
449 /// # Examples
450 ///
451 /// Using a struct:
452 /// ```rust,no_run
453 /// use screencapturekit::prelude::*;
454 ///
455 /// struct MyHandler;
456 /// impl SCStreamOutputTrait for MyHandler {
457 /// fn did_output_sample_buffer(&self, _sample: CMSampleBuffer, _of_type: SCStreamOutputType) {
458 /// println!("Got frame!");
459 /// }
460 /// }
461 ///
462 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
463 /// # let content = SCShareableContent::get()?;
464 /// # let display = &content.displays()[0];
465 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build();
466 /// # let config = SCStreamConfiguration::default();
467 /// let mut stream = SCStream::new(&filter, &config);
468 /// stream.add_output_handler(MyHandler, SCStreamOutputType::Screen);
469 /// # Ok(())
470 /// # }
471 /// ```
472 ///
473 /// Using a closure:
474 /// ```rust,no_run
475 /// use screencapturekit::prelude::*;
476 ///
477 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
478 /// # let content = SCShareableContent::get()?;
479 /// # let display = &content.displays()[0];
480 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build();
481 /// # let config = SCStreamConfiguration::default();
482 /// let mut stream = SCStream::new(&filter, &config);
483 /// stream.add_output_handler(
484 /// |_sample, _type| println!("Got frame!"),
485 /// SCStreamOutputType::Screen
486 /// );
487 /// # Ok(())
488 /// # }
489 /// ```
490 ///
491 /// # Sharing state with handlers
492 ///
493 /// The handler bound is `impl SCStreamOutputTrait + 'static`. The
494 /// `'static` is required because the handler is stored inside
495 /// `SCStream` which can outlive any borrowed reference. Combined
496 /// with the trait's `Send + Sync` bound (callbacks run on
497 /// independent dispatch queues, see
498 /// [`SCStreamOutputTrait`](crate::stream::output_trait::SCStreamOutputTrait)),
499 /// the canonical pattern for sharing state with a handler is to
500 /// wrap it in `Arc<Mutex<T>>` (or `Arc<AtomicXxx>` for primitives):
501 ///
502 /// ```rust,no_run
503 /// use screencapturekit::prelude::*;
504 /// use std::sync::{Arc, Mutex, atomic::{AtomicUsize, Ordering}};
505 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
506 /// # let content = SCShareableContent::get()?;
507 /// # let display = &content.displays()[0];
508 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build();
509 /// # let config = SCStreamConfiguration::default();
510 /// let frame_count = Arc::new(AtomicUsize::new(0));
511 /// let count_handler = frame_count.clone();
512 /// let mut stream = SCStream::new(&filter, &config);
513 /// stream.add_output_handler(
514 /// move |_sample, _type| {
515 /// count_handler.fetch_add(1, Ordering::Relaxed);
516 /// },
517 /// SCStreamOutputType::Screen,
518 /// );
519 /// // outer scope can still read frame_count any time:
520 /// println!("frames so far: {}", frame_count.load(Ordering::Relaxed));
521 /// # Ok(())
522 /// # }
523 /// ```
524 pub fn add_output_handler(
525 &mut self,
526 handler: impl SCStreamOutputTrait + 'static,
527 of_type: SCStreamOutputType,
528 ) -> Option<usize> {
529 self.add_output_handler_with_queue(handler, of_type, None)
530 }
531
532 /// Add an output handler with a custom dispatch queue
533 ///
534 /// This allows controlling which thread/queue the handler is called on.
535 ///
536 /// # Arguments
537 ///
538 /// * `handler` - The handler to receive callbacks
539 /// * `of_type` - The type of output to receive
540 /// * `queue` - Optional custom dispatch queue for callbacks
541 ///
542 /// # Examples
543 ///
544 /// ```rust,no_run
545 /// use screencapturekit::prelude::*;
546 /// use screencapturekit::dispatch_queue::{DispatchQueue, DispatchQoS};
547 ///
548 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
549 /// # let content = SCShareableContent::get()?;
550 /// # let display = &content.displays()[0];
551 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build();
552 /// # let config = SCStreamConfiguration::default();
553 /// let mut stream = SCStream::new(&filter, &config);
554 /// let queue = DispatchQueue::new("com.myapp.capture", DispatchQoS::UserInteractive);
555 ///
556 /// stream.add_output_handler_with_queue(
557 /// |_sample, _type| println!("Got frame on custom queue!"),
558 /// SCStreamOutputType::Screen,
559 /// Some(&queue)
560 /// );
561 /// # Ok(())
562 /// # }
563 /// ```
564 pub fn add_output_handler_with_queue(
565 &mut self,
566 handler: impl SCStreamOutputTrait + 'static,
567 of_type: SCStreamOutputType,
568 queue: Option<&DispatchQueue>,
569 ) -> Option<usize> {
570 let handler_id = NEXT_HANDLER_ID.fetch_add(1, Ordering::Relaxed);
571
572 // Convert output type to int for Swift
573 let output_type_int = match of_type {
574 SCStreamOutputType::Screen => 0,
575 SCStreamOutputType::Audio => 1,
576 SCStreamOutputType::Microphone => 2,
577 };
578
579 let ok = if let Some(q) = queue {
580 unsafe {
581 ffi::sc_stream_add_stream_output_with_queue(self.ptr, output_type_int, q.as_ptr())
582 }
583 } else {
584 unsafe { ffi::sc_stream_add_stream_output(self.ptr, output_type_int) }
585 };
586
587 if ok {
588 // SAFETY: self.context is the Box::into_raw StreamContext created in
589 // SCStream::new; it stays valid for the lifetime of self (released
590 // only in Drop, after this method returns).
591 unsafe { &*self.context }
592 .handlers
593 .write()
594 .unwrap_or_else(std::sync::PoisonError::into_inner)
595 .push(HandlerEntry {
596 id: handler_id,
597 of_type,
598 handler: Box::new(handler),
599 });
600 Some(handler_id)
601 } else {
602 // Surface the failure rather than dropping it silently — registration
603 // only fails if ScreenCaptureKit rejects `addStreamOutput` (e.g. the
604 // output type is not enabled by the stream configuration). The caller
605 // still gets `None`, but this makes the cause visible in logs.
606 eprintln!(
607 "SCStream: failed to register output handler for {of_type:?} \
608 (ScreenCaptureKit rejected addStreamOutput)"
609 );
610 None
611 }
612 }
613
614 /// Remove an output handler
615 ///
616 /// # Arguments
617 ///
618 /// * `id` - The handler ID returned from [`add_output_handler`](Self::add_output_handler)
619 /// * `of_type` - The type of output the handler was registered for
620 ///
621 /// # Returns
622 ///
623 /// Returns `true` if the handler was found and removed, `false` otherwise.
624 pub fn remove_output_handler(&mut self, id: usize, of_type: SCStreamOutputType) -> bool {
625 // SAFETY: self.context is the Box::into_raw StreamContext created in
626 // SCStream::new; it stays valid for the lifetime of self.
627 let mut handlers = unsafe { &*self.context }
628 .handlers
629 .write()
630 .unwrap_or_else(std::sync::PoisonError::into_inner);
631 let Some(pos) = handlers.iter().position(|e| e.id == id) else {
632 return false;
633 };
634 handlers.remove(pos);
635
636 // If no more handlers for this output type, tell Swift to remove the output
637 let has_type = handlers.iter().any(|e| e.of_type == of_type);
638 drop(handlers);
639
640 if !has_type {
641 let output_type_int = match of_type {
642 SCStreamOutputType::Screen => 0,
643 SCStreamOutputType::Audio => 1,
644 SCStreamOutputType::Microphone => 2,
645 };
646 unsafe { ffi::sc_stream_remove_stream_output(self.ptr, output_type_int) };
647 }
648
649 true
650 }
651
652 /// Start capturing screen content
653 ///
654 /// This method blocks until the capture operation completes or fails.
655 ///
656 /// # Errors
657 ///
658 /// Returns `SCError::CaptureStartFailed` if the capture fails to start.
659 pub fn start_capture(&self) -> Result<(), SCError> {
660 let (completion, context) = UnitCompletion::new();
661 unsafe { ffi::sc_stream_start_capture(self.ptr, context, UnitCompletion::callback) };
662 completion.wait().map_err(SCError::CaptureStartFailed)
663 }
664
665 /// Stop capturing screen content
666 ///
667 /// This method blocks until the capture operation completes or fails.
668 ///
669 /// # Errors
670 ///
671 /// Returns `SCError::CaptureStopFailed` if the capture fails to stop.
672 pub fn stop_capture(&self) -> Result<(), SCError> {
673 let (completion, context) = UnitCompletion::new();
674 unsafe { ffi::sc_stream_stop_capture(self.ptr, context, UnitCompletion::callback) };
675 completion.wait().map_err(SCError::CaptureStopFailed)
676 }
677
678 /// Update the stream configuration
679 ///
680 /// This method blocks until the configuration update completes or fails.
681 ///
682 /// # Errors
683 ///
684 /// Returns `SCError::StreamError` if the configuration update fails.
685 pub fn update_configuration(
686 &self,
687 configuration: &SCStreamConfiguration,
688 ) -> Result<(), SCError> {
689 let (completion, context) = UnitCompletion::new();
690 unsafe {
691 ffi::sc_stream_update_configuration(
692 self.ptr,
693 configuration.as_ptr(),
694 context,
695 UnitCompletion::callback,
696 );
697 }
698 completion.wait().map_err(SCError::StreamError)
699 }
700
701 /// Update the content filter
702 ///
703 /// This method blocks until the filter update completes or fails.
704 ///
705 /// # Errors
706 ///
707 /// Returns `SCError::StreamError` if the filter update fails.
708 pub fn update_content_filter(&self, filter: &SCContentFilter) -> Result<(), SCError> {
709 let (completion, context) = UnitCompletion::new();
710 unsafe {
711 ffi::sc_stream_update_content_filter(
712 self.ptr,
713 filter.as_ptr(),
714 context,
715 UnitCompletion::callback,
716 );
717 }
718 completion.wait().map_err(SCError::StreamError)
719 }
720
721 /// Get the synchronization clock for this stream (macOS 13.0+)
722 ///
723 /// Returns the `CMClock` used to synchronize the stream's output.
724 /// This is useful for coordinating multiple streams or synchronizing
725 /// with other media.
726 ///
727 /// Returns `None` if the clock is not available (e.g., stream not started
728 /// or macOS version too old).
729 #[cfg(feature = "macos_13_0")]
730 pub fn synchronization_clock(&self) -> Option<crate::cm::CMClock> {
731 let ptr = unsafe { ffi::sc_stream_get_synchronization_clock(self.ptr) };
732 // SAFETY: the Swift thunk returns a +0 (unretained) reference and
733 // CMClock::from_raw retains it, so ownership is balanced (no leak).
734 crate::cm::CMClock::from_raw(ptr)
735 }
736
737 /// Add a recording output to the stream (macOS 15.0+)
738 ///
739 /// Starts recording if the stream is already capturing, otherwise recording
740 /// will start when capture begins. The recording is written to the file URL
741 /// specified in the `SCRecordingOutputConfiguration`.
742 ///
743 /// # Errors
744 ///
745 /// Returns `SCError::StreamError` if adding the recording output fails.
746 #[cfg(feature = "macos_15_0")]
747 pub fn add_recording_output(
748 &self,
749 recording_output: &crate::recording_output::SCRecordingOutput,
750 ) -> Result<(), SCError> {
751 let (completion, context) = UnitCompletion::new();
752 unsafe {
753 ffi::sc_stream_add_recording_output(
754 self.ptr,
755 recording_output.as_ptr(),
756 UnitCompletion::callback,
757 context,
758 );
759 }
760 completion.wait().map_err(SCError::StreamError)
761 }
762
763 /// Remove a recording output from the stream (macOS 15.0+)
764 ///
765 /// Stops recording if the stream is currently recording.
766 ///
767 /// # Errors
768 ///
769 /// Returns `SCError::StreamError` if removing the recording output fails.
770 #[cfg(feature = "macos_15_0")]
771 pub fn remove_recording_output(
772 &self,
773 recording_output: &crate::recording_output::SCRecordingOutput,
774 ) -> Result<(), SCError> {
775 let (completion, context) = UnitCompletion::new();
776 unsafe {
777 ffi::sc_stream_remove_recording_output(
778 self.ptr,
779 recording_output.as_ptr(),
780 UnitCompletion::callback,
781 context,
782 );
783 }
784 completion.wait().map_err(SCError::StreamError)
785 }
786
787 /// Returns the raw pointer to the underlying Swift `SCStream` instance.
788 #[allow(dead_code)]
789 pub(crate) fn as_ptr(&self) -> *const c_void {
790 self.ptr
791 }
792}
793
794impl Drop for SCStream {
795 // Safety / teardown ordering:
796 //
797 // `sc_stream_release` removes this stream's entry from the Swift-side stream
798 // registry and releases the `SCStream`, which detaches the stream-output
799 // delegate so no *new* callbacks can be dispatched referencing `self.context`.
800 // We release the `StreamContext` only afterwards, so the ordering here is
801 // release-stream-then-release-context.
802 //
803 // In-flight callbacks are safe even though Apple's stop is asynchronous: the
804 // Swift `StreamDelegateWrapper` and `StreamOutputHandler` objects each hold
805 // their own +1 reference on the `StreamContext` (taken in `init` via
806 // `context_retain_cb`, dropped in `deinit` via `context_release_cb`). Each
807 // callback runs as a method on one of those objects, and ARC keeps that
808 // object (`self`) alive for the duration of the call, so its context
809 // reference is also held for the duration of the call. Therefore a callback
810 // already in flight can never observe a freed context: the final
811 // `Box::from_raw` only happens once every holder — this Rust `SCStream` and
812 // both Swift bridge objects — has released its reference.
813 //
814 // Refcount accounting: `StreamContext::new` starts at 1; the Swift
815 // `createStream` adds +1 per bridge object (delegate + output handler) = 3;
816 // this `drop` removes -1 = 2; each bridge object's `deinit` removes -1,
817 // reaching 0 and freeing the context.
818 fn drop(&mut self) {
819 if !self.ptr.is_null() {
820 unsafe { ffi::sc_stream_release(self.ptr) };
821 }
822 unsafe { StreamContext::release(self.context) };
823 }
824}
825
826impl Clone for SCStream {
827 /// Clone the stream reference.
828 ///
829 /// Cloning an `SCStream` creates a new reference to the same underlying
830 /// Swift `SCStream` object. The cloned stream shares the same handlers
831 /// as the original — they receive frames from the same capture session.
832 ///
833 /// Both the original and cloned stream share the same capture state, so:
834 /// - Starting capture on one affects both
835 /// - Stopping capture on one affects both
836 /// - Configuration updates affect both
837 /// - Handlers receive the same frames
838 ///
839 /// # Examples
840 ///
841 /// ```rust,no_run
842 /// use screencapturekit::prelude::*;
843 ///
844 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
845 /// # let content = SCShareableContent::get()?;
846 /// # let display = &content.displays()[0];
847 /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build();
848 /// # let config = SCStreamConfiguration::default();
849 /// let mut stream = SCStream::new(&filter, &config);
850 /// stream.add_output_handler(|_, _| println!("Handler 1"), SCStreamOutputType::Screen);
851 ///
852 /// // Clone shares the same handlers
853 /// let stream2 = stream.clone();
854 /// // Both stream and stream2 will receive frames via Handler 1
855 /// # Ok(())
856 /// # }
857 /// ```
858 fn clone(&self) -> Self {
859 unsafe { StreamContext::retain(self.context) };
860
861 Self {
862 ptr: unsafe { crate::ffi::sc_stream_retain(self.ptr) },
863 context: self.context,
864 }
865 }
866}
867
868impl fmt::Debug for SCStream {
869 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
870 f.debug_struct("SCStream")
871 .field("ptr", &self.ptr)
872 .finish_non_exhaustive()
873 }
874}
875
876impl fmt::Display for SCStream {
877 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
878 write!(f, "SCStream")
879 }
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885 use std::sync::atomic::AtomicUsize;
886 use std::sync::Arc;
887
888 /// Regression test for #135: multiple concurrent streams must not leak
889 /// samples across each other.
890 ///
891 /// Creates two independent `StreamContexts` with separate handlers and
892 /// directly invokes each context's handlers. Verifies that each handler
893 /// only receives calls routed through its own context — not from the
894 /// other context. With the old global `HANDLER_REGISTRY`, both handlers
895 /// would have been called for every callback regardless of context.
896 #[test]
897 fn test_per_stream_callback_isolation() {
898 let count_a = Arc::new(AtomicUsize::new(0));
899 let count_b = Arc::new(AtomicUsize::new(0));
900
901 // Create two independent contexts (simulates two SCStream instances)
902 let ctx_a = StreamContext::new();
903 let ctx_b = StreamContext::new();
904
905 // Register an audio handler on context A
906 {
907 let counter = count_a.clone();
908 let mut handlers = unsafe { &*ctx_a }
909 .handlers
910 .write()
911 .unwrap_or_else(std::sync::PoisonError::into_inner);
912 handlers.push(HandlerEntry {
913 id: 1,
914 of_type: SCStreamOutputType::Audio,
915 handler: Box::new(
916 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
917 counter.fetch_add(1, Ordering::Relaxed);
918 // Prevent Drop from calling cm_sample_buffer_release on our fake pointer
919 std::mem::forget(buf);
920 },
921 ),
922 });
923 }
924
925 // Register an audio handler on context B
926 {
927 let counter = count_b.clone();
928 let mut handlers = unsafe { &*ctx_b }
929 .handlers
930 .write()
931 .unwrap_or_else(std::sync::PoisonError::into_inner);
932 handlers.push(HandlerEntry {
933 id: 2,
934 of_type: SCStreamOutputType::Audio,
935 handler: Box::new(
936 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
937 counter.fetch_add(1, Ordering::Relaxed);
938 std::mem::forget(buf);
939 },
940 ),
941 });
942 }
943
944 // Simulate 5 audio callbacks on context A by directly calling matching handlers
945 for _ in 0..5 {
946 let handlers = unsafe { &*ctx_a }
947 .handlers
948 .write()
949 .unwrap_or_else(std::sync::PoisonError::into_inner);
950 for entry in handlers
951 .iter()
952 .filter(|e| e.of_type == SCStreamOutputType::Audio)
953 {
954 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
955 entry
956 .handler
957 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
958 }
959 }
960
961 // Simulate 3 audio callbacks on context B
962 for _ in 0..3 {
963 let handlers = unsafe { &*ctx_b }
964 .handlers
965 .write()
966 .unwrap_or_else(std::sync::PoisonError::into_inner);
967 for entry in handlers
968 .iter()
969 .filter(|e| e.of_type == SCStreamOutputType::Audio)
970 {
971 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
972 entry
973 .handler
974 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
975 }
976 }
977
978 // Handler A must have received exactly 5 — not 8
979 assert_eq!(
980 count_a.load(Ordering::Relaxed),
981 5,
982 "handler A received callbacks meant for B (cross-stream leak)"
983 );
984 // Handler B must have received exactly 3 — not 8
985 assert_eq!(
986 count_b.load(Ordering::Relaxed),
987 3,
988 "handler B received callbacks meant for A (cross-stream leak)"
989 );
990
991 unsafe {
992 StreamContext::release(ctx_a);
993 StreamContext::release(ctx_b);
994 }
995 }
996
997 /// Verify that handlers are filtered by output type within a single context.
998 #[test]
999 fn test_handler_output_type_filtering() {
1000 let screen_count = Arc::new(AtomicUsize::new(0));
1001 let audio_count = Arc::new(AtomicUsize::new(0));
1002
1003 let ctx = StreamContext::new();
1004
1005 {
1006 let counter = screen_count.clone();
1007 let mut handlers = unsafe { &*ctx }
1008 .handlers
1009 .write()
1010 .unwrap_or_else(std::sync::PoisonError::into_inner);
1011 handlers.push(HandlerEntry {
1012 id: 1,
1013 of_type: SCStreamOutputType::Screen,
1014 handler: Box::new(
1015 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1016 counter.fetch_add(1, Ordering::Relaxed);
1017 std::mem::forget(buf);
1018 },
1019 ),
1020 });
1021 }
1022 {
1023 let counter = audio_count.clone();
1024 let mut handlers = unsafe { &*ctx }
1025 .handlers
1026 .write()
1027 .unwrap_or_else(std::sync::PoisonError::into_inner);
1028 handlers.push(HandlerEntry {
1029 id: 2,
1030 of_type: SCStreamOutputType::Audio,
1031 handler: Box::new(
1032 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1033 counter.fetch_add(1, Ordering::Relaxed);
1034 std::mem::forget(buf);
1035 },
1036 ),
1037 });
1038 }
1039
1040 // Send 4 screen callbacks
1041 for _ in 0..4 {
1042 let handlers = unsafe { &*ctx }
1043 .handlers
1044 .write()
1045 .unwrap_or_else(std::sync::PoisonError::into_inner);
1046 for entry in handlers
1047 .iter()
1048 .filter(|e| e.of_type == SCStreamOutputType::Screen)
1049 {
1050 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1051 entry
1052 .handler
1053 .did_output_sample_buffer(buf, SCStreamOutputType::Screen);
1054 }
1055 }
1056
1057 // Send 2 audio callbacks
1058 for _ in 0..2 {
1059 let handlers = unsafe { &*ctx }
1060 .handlers
1061 .write()
1062 .unwrap_or_else(std::sync::PoisonError::into_inner);
1063 for entry in handlers
1064 .iter()
1065 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1066 {
1067 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1068 entry
1069 .handler
1070 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1071 }
1072 }
1073
1074 assert_eq!(screen_count.load(Ordering::Relaxed), 4);
1075 assert_eq!(audio_count.load(Ordering::Relaxed), 2);
1076
1077 unsafe { StreamContext::release(ctx) };
1078 }
1079
1080 /// Verify that `StreamContext` ref counting works correctly.
1081 #[test]
1082 fn test_stream_context_ref_counting() {
1083 let ctx = StreamContext::new();
1084
1085 // Initial ref count is 1
1086 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 1);
1087
1088 // Retain bumps to 2
1089 unsafe { StreamContext::retain(ctx) };
1090 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 2);
1091
1092 // First release drops to 1 — context still alive
1093 unsafe { StreamContext::release(ctx) };
1094 assert_eq!(unsafe { &*ctx }.ref_count.load(Ordering::Relaxed), 1);
1095
1096 // Second release drops to 0 — context freed (no crash = success)
1097 unsafe { StreamContext::release(ctx) };
1098 }
1099
1100 /// Regression test: a panic in a user-supplied output handler must NOT
1101 /// poison the handlers `RwLock`, must NOT propagate across the C ABI,
1102 /// and must NOT prevent subsequent callbacks from being dispatched.
1103 ///
1104 /// This validates the C1+C2 fix from the deep review: `catch_unwind`
1105 /// around user dispatch and `RwLock` poisoning recovery via
1106 /// `unwrap_or_else(PoisonError::into_inner)` together prevent one
1107 /// panicking handler from permanently breaking the stream.
1108 #[test]
1109 fn test_panic_in_handler_is_isolated() {
1110 // Set a no-op panic hook so our intentional panic doesn't spam the
1111 // test output. We restore it at the end of the test.
1112 let original_hook = std::panic::take_hook();
1113 std::panic::set_hook(Box::new(|_| {}));
1114
1115 let panicked_count = Arc::new(AtomicUsize::new(0));
1116 let normal_count = Arc::new(AtomicUsize::new(0));
1117
1118 let ctx = StreamContext::new();
1119
1120 // Handler 1: always panics
1121 {
1122 let counter = panicked_count.clone();
1123 let mut handlers = unsafe { &*ctx }
1124 .handlers
1125 .write()
1126 .unwrap_or_else(std::sync::PoisonError::into_inner);
1127 handlers.push(HandlerEntry {
1128 id: 1,
1129 of_type: SCStreamOutputType::Audio,
1130 handler: Box::new(
1131 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1132 counter.fetch_add(1, Ordering::Relaxed);
1133 std::mem::forget(buf);
1134 panic!("intentional test panic");
1135 },
1136 ),
1137 });
1138 }
1139
1140 // Handler 2: well-behaved, registered AFTER the panicker
1141 {
1142 let counter = normal_count.clone();
1143 let mut handlers = unsafe { &*ctx }
1144 .handlers
1145 .write()
1146 .unwrap_or_else(std::sync::PoisonError::into_inner);
1147 handlers.push(HandlerEntry {
1148 id: 2,
1149 of_type: SCStreamOutputType::Audio,
1150 handler: Box::new(
1151 move |buf: crate::cm::CMSampleBuffer, _ty: SCStreamOutputType| {
1152 counter.fetch_add(1, Ordering::Relaxed);
1153 std::mem::forget(buf);
1154 },
1155 ),
1156 });
1157 }
1158
1159 // Simulate 5 callbacks. Each iteration, the panicker fires (and
1160 // panics), then the well-behaved handler must still fire on the
1161 // SAME callback because both handlers match the output type. We
1162 // simulate the dispatch path without going through the C callback
1163 // (which would require a real CMSampleBuffer); the key behaviour
1164 // we're verifying is that the lock isn't poisoned and that the
1165 // catch_unwind boundary contains the panic.
1166 for _ in 0..5 {
1167 let handlers = unsafe { &*ctx }
1168 .handlers
1169 .read()
1170 .unwrap_or_else(std::sync::PoisonError::into_inner);
1171 for entry in handlers
1172 .iter()
1173 .filter(|e| e.of_type == SCStreamOutputType::Audio)
1174 {
1175 let buf = unsafe { crate::cm::CMSampleBuffer::from_ptr(std::ptr::null_mut()) };
1176 catch_user_panic("test handler", || {
1177 entry
1178 .handler
1179 .did_output_sample_buffer(buf, SCStreamOutputType::Audio);
1180 });
1181 }
1182 }
1183
1184 // Both handlers fired 5 times each — the panicker did not stop the
1185 // dispatch loop or poison the lock for subsequent reads.
1186 assert_eq!(
1187 panicked_count.load(Ordering::Relaxed),
1188 5,
1189 "panicking handler stopped firing after first panic"
1190 );
1191 assert_eq!(
1192 normal_count.load(Ordering::Relaxed),
1193 5,
1194 "well-behaved handler stopped firing after panicker poisoned state"
1195 );
1196
1197 // Lock is still acquirable (would otherwise be poisoned).
1198 drop(
1199 unsafe { &*ctx }
1200 .handlers
1201 .write()
1202 .unwrap_or_else(std::sync::PoisonError::into_inner),
1203 );
1204
1205 unsafe { StreamContext::release(ctx) };
1206
1207 // Restore the original panic hook so other tests behave normally.
1208 std::panic::set_hook(original_hook);
1209 }
1210}