screencapturekit/content_sharing_picker.rs
1//! `SCContentSharingPicker` - UI for selecting content to share
2//!
3//! Available on macOS 14.0+.
4//! Provides a system UI for users to select displays, windows, or applications to share.
5//!
6//! ## When to Use
7//!
8//! Use the content sharing picker when:
9//! - You want users to choose what to capture via a native macOS UI
10//! - You need consistent UX with other screen sharing apps
11//! - You want to avoid manually listing and presenting content options
12//!
13//! ## APIs
14//!
15//! | Method | Returns | Use Case |
16//! |--------|---------|----------|
17//! | [`SCContentSharingPicker::show()`] | callback with [`SCPickerOutcome`] | Get filter + metadata (dimensions, picked content) |
18//! | [`SCContentSharingPicker::show_filter()`] | callback with [`SCPickerFilterOutcome`] | Just get the filter |
19//!
20//! For async/await, use `AsyncSCContentSharingPicker` from the optional
21//! `async_api` module.
22//!
23//! # Examples
24//!
25//! ## Callback API: Get filter with metadata
26//! ```no_run
27//! use screencapturekit::content_sharing_picker::*;
28//! use screencapturekit::prelude::*;
29//!
30//! let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
31//! SCContentSharingPicker::show(&config, |outcome| {
32//! match outcome {
33//! SCPickerOutcome::Picked(result) => {
34//! let (width, height) = result.pixel_size();
35//! let filter = result.filter();
36//! println!("Selected content: {}x{}", width, height);
37//! // Create stream with the filter...
38//! }
39//! SCPickerOutcome::Cancelled => println!("Cancelled"),
40//! SCPickerOutcome::Error(e) => eprintln!("Error: {}", e),
41//! }
42//! });
43//! ```
44//!
45//! ## Async API
46//! ```no_run
47//! use screencapturekit::async_api::AsyncSCContentSharingPicker;
48//! use screencapturekit::content_sharing_picker::*;
49//!
50//! async fn example() {
51//! let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
52//! if let SCPickerOutcome::Picked(result) = AsyncSCContentSharingPicker::show(&config).await {
53//! let (width, height) = result.pixel_size();
54//! let filter = result.filter();
55//! println!("Selected: {}x{}", width, height);
56//! }
57//! }
58//! ```
59//!
60//! ## Configure Picker Modes
61//! ```no_run
62//! use screencapturekit::content_sharing_picker::*;
63//!
64//! let mut config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
65//! // Only allow single display selection
66//! config.set_allowed_picker_modes(&[SCContentSharingPickerMode::SingleDisplay]);
67//! // Exclude specific apps from the picker
68//! config.set_excluded_bundle_ids(&["com.apple.finder", "com.apple.dock"]).expect("bundle IDs have no NUL byte");
69//! ```
70
71use crate::error::SCError;
72use crate::stream::configuration::InteriorNulError;
73use crate::stream::content_filter::{SCContentFilter, SCShareableContentStyle};
74pub use crate::stream::StreamIdentity;
75use std::any::Any;
76use std::collections::HashMap;
77use std::ffi::c_void;
78use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
79use std::sync::{Mutex, PoisonError};
80
81/// Represents the type of content selected in the picker
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum SCPickedSource {
84 /// A window was selected, with its title
85 Window(String),
86 /// A display was selected, with its ID
87 Display(u32),
88 /// An application was selected, with its name
89 Application(String),
90 /// No specific source identified
91 Unknown,
92}
93
94/// Picker mode determines what content types can be selected
95///
96/// These modes can be combined to allow users to pick from different source types.
97#[repr(i32)]
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
99pub enum SCContentSharingPickerMode {
100 /// Allow selection of a single window
101 #[default]
102 SingleWindow = 0,
103 /// Allow selection of multiple windows
104 MultipleWindows = 1,
105 /// Allow selection of a single display/screen
106 SingleDisplay = 2,
107 /// Allow selection of a single application
108 SingleApplication = 3,
109 /// Allow selection of multiple applications
110 MultipleApplications = 4,
111}
112
113/// Configuration for the content sharing picker
114pub struct SCContentSharingPickerConfiguration {
115 ptr: *const c_void,
116}
117
118impl SCContentSharingPickerConfiguration {
119 /// # Errors
120 ///
121 /// Returns [`SCError::FeatureNotAvailable`] when run on macOS older than
122 /// 14.0.
123 pub fn new() -> Result<Self, SCError> {
124 if !SCContentSharingPicker::is_available() {
125 return Err(SCError::feature_not_available(
126 "SCContentSharingPicker",
127 "14.0",
128 ));
129 }
130 let ptr = unsafe { crate::ffi::sc_content_sharing_picker_configuration_create() };
131 if ptr.is_null() {
132 return Err(SCError::null_pointer("SCContentSharingPickerConfiguration"));
133 }
134 Ok(Self { ptr })
135 }
136
137 /// Construct a configuration initialised with the system's default values
138 /// (the equivalent of Apple's `SCContentSharingPicker.shared.defaultConfiguration`).
139 ///
140 /// Use this when you want "system defaults plus my one tweak" — call this
141 /// to get the baseline, then mutate the fields you care about. Compared
142 /// to [`SCContentSharingPickerConfiguration::new()`], which starts from a
143 /// blank-slate `SCContentSharingPickerConfiguration()`, this preserves
144 /// any system-wide picker preferences the OS applies to fresh
145 /// configurations (e.g. allowed picker modes, default exclusion lists).
146 ///
147 /// # Errors
148 ///
149 /// Returns [`SCError::FeatureNotAvailable`] when run on macOS older than
150 /// 14.0.
151 ///
152 /// # Examples
153 ///
154 /// ```no_run
155 /// use screencapturekit::content_sharing_picker::*;
156 ///
157 /// // Start from the system defaults, then override only what you need.
158 /// let mut config = SCContentSharingPickerConfiguration::default_from_system()
159 /// .expect("read the default picker configuration");
160 /// config.set_excluded_bundle_ids(&["com.apple.dock"]).expect("bundle IDs have no NUL byte");
161 /// ```
162 pub fn default_from_system() -> Result<Self, SCError> {
163 if !SCContentSharingPicker::is_available() {
164 return Err(SCError::feature_not_available(
165 "SCContentSharingPicker",
166 "14.0",
167 ));
168 }
169 let ptr = unsafe { crate::ffi::sc_content_sharing_picker_create_default_configuration() };
170 if ptr.is_null() {
171 return Err(SCError::null_pointer("SCContentSharingPickerConfiguration"));
172 }
173 Ok(Self { ptr })
174 }
175
176 /// Set allowed picker modes
177 pub fn set_allowed_picker_modes(&mut self, modes: &[SCContentSharingPickerMode]) {
178 let mode_values: Vec<i32> = modes.iter().map(|m| *m as i32).collect();
179 unsafe {
180 crate::ffi::sc_content_sharing_picker_configuration_set_allowed_picker_modes(
181 self.ptr,
182 mode_values.as_ptr(),
183 mode_values.len(),
184 );
185 }
186 }
187
188 /// Get the currently allowed picker modes.
189 pub fn allowed_picker_modes(&self) -> Vec<SCContentSharingPickerMode> {
190 let mask = unsafe {
191 crate::ffi::sc_content_sharing_picker_configuration_get_allowed_picker_modes_mask(
192 self.ptr,
193 )
194 };
195 let mut modes = Vec::new();
196 for (raw_value, mode) in [
197 (1_u64, SCContentSharingPickerMode::SingleWindow),
198 (2_u64, SCContentSharingPickerMode::MultipleWindows),
199 (16_u64, SCContentSharingPickerMode::SingleDisplay),
200 (4_u64, SCContentSharingPickerMode::SingleApplication),
201 (8_u64, SCContentSharingPickerMode::MultipleApplications),
202 ] {
203 if mask & raw_value != 0 {
204 modes.push(mode);
205 }
206 }
207 modes
208 }
209
210 /// Set whether the user can change the selected content while sharing
211 ///
212 /// When `true`, the user can modify their selection during an active session.
213 pub fn set_allows_changing_selected_content(&mut self, allows: bool) {
214 unsafe {
215 crate::ffi::sc_content_sharing_picker_configuration_set_allows_changing_selected_content(
216 self.ptr,
217 allows,
218 );
219 }
220 }
221
222 /// Get whether changing selected content is allowed
223 pub fn allows_changing_selected_content(&self) -> bool {
224 unsafe {
225 crate::ffi::sc_content_sharing_picker_configuration_get_allows_changing_selected_content(
226 self.ptr,
227 )
228 }
229 }
230
231 /// Set bundle identifiers to exclude from the picker
232 ///
233 /// Applications with these bundle IDs will not appear in the picker.
234 ///
235 /// # Errors
236 ///
237 /// Returns [`InteriorNulError`] — leaving the configuration unchanged — if
238 /// any bundle ID contains an interior NUL byte.
239 pub fn set_excluded_bundle_ids(&mut self, bundle_ids: &[&str]) -> Result<(), InteriorNulError> {
240 let c_strings = bundle_ids
241 .iter()
242 .map(|id| std::ffi::CString::new(*id))
243 .collect::<Result<Vec<_>, _>>()
244 .map_err(|_| InteriorNulError)?;
245 let ptrs: Vec<*const i8> = c_strings.iter().map(|s| s.as_ptr()).collect();
246 unsafe {
247 crate::ffi::sc_content_sharing_picker_configuration_set_excluded_bundle_ids(
248 self.ptr,
249 ptrs.as_ptr(),
250 ptrs.len(),
251 );
252 }
253 Ok(())
254 }
255
256 /// Get the list of excluded bundle identifiers
257 ///
258 /// A bundle ID that does not fit in the transfer buffer is skipped rather
259 /// than returned truncated, so the result can be shorter than
260 /// [`excluded_bundle_ids_count`](Self::excluded_bundle_ids_count).
261 #[must_use]
262 pub fn excluded_bundle_ids(&self) -> Vec<String> {
263 let count = self.excluded_bundle_ids_count();
264 let mut result = Vec::with_capacity(count);
265 for i in 0..count {
266 let id = unsafe {
267 crate::utils::ffi_string::ffi_string_from_buffer(
268 crate::utils::ffi_string::DEFAULT_BUFFER_SIZE,
269 |buffer, len| {
270 crate::ffi::sc_content_sharing_picker_configuration_get_excluded_bundle_id_at(
271 self.ptr,
272 i,
273 buffer,
274 usize::try_from(len).unwrap_or(0),
275 )
276 },
277 )
278 };
279 if let Some(id) = id {
280 result.push(id);
281 }
282 }
283 result
284 }
285
286 /// Number of excluded bundle identifiers configured.
287 #[must_use]
288 pub fn excluded_bundle_ids_count(&self) -> usize {
289 unsafe {
290 crate::ffi::sc_content_sharing_picker_configuration_get_excluded_bundle_ids_count(
291 self.ptr,
292 )
293 }
294 }
295
296 /// Set window IDs to exclude from the picker
297 ///
298 /// Windows with these IDs will not appear in the picker.
299 pub fn set_excluded_window_ids(&mut self, window_ids: &[u32]) {
300 unsafe {
301 crate::ffi::sc_content_sharing_picker_configuration_set_excluded_window_ids(
302 self.ptr,
303 window_ids.as_ptr(),
304 window_ids.len(),
305 );
306 }
307 }
308
309 /// Get the list of excluded window IDs
310 pub fn excluded_window_ids(&self) -> Vec<u32> {
311 let count = unsafe {
312 crate::ffi::sc_content_sharing_picker_configuration_get_excluded_window_ids_count(
313 self.ptr,
314 )
315 };
316 let mut result = Vec::with_capacity(count);
317 for i in 0..count {
318 let id = unsafe {
319 crate::ffi::sc_content_sharing_picker_configuration_get_excluded_window_id_at(
320 self.ptr, i,
321 )
322 };
323 result.push(id);
324 }
325 result
326 }
327
328 #[must_use]
329 pub const fn as_ptr(&self) -> *const c_void {
330 self.ptr
331 }
332}
333
334crate::utils::retained::sc_retained!(
335 SCContentSharingPickerConfiguration,
336 field = ptr,
337 release = crate::ffi::sc_content_sharing_picker_configuration_release,
338);
339
340impl Clone for SCContentSharingPickerConfiguration {
341 /// Produce an independent configuration with the same values.
342 ///
343 /// Deliberately **not** the retain-based clone the other wrappers in this
344 /// crate use. Those wrap immutable Objective-C objects, where sharing one
345 /// instance between handles is unobservable. This type is different: it
346 /// wraps a mutable Swift box and exposes `&mut self` setters, so a
347 /// refcount-only clone would let a `&mut` on one handle mutate state that
348 /// another handle observes through a shared `&` — and, with `Send + Sync`,
349 /// from another thread at the same time.
350 fn clone(&self) -> Self {
351 Self {
352 ptr: unsafe { crate::ffi::sc_content_sharing_picker_configuration_copy(self.ptr) },
353 }
354 }
355}
356
357impl std::fmt::Debug for SCContentSharingPickerConfiguration {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 f.debug_struct("SCContentSharingPickerConfiguration")
360 .field("ptr", &self.ptr)
361 .finish()
362 }
363}
364
365// ============================================================================
366// Simple API: Returns SCContentFilter directly
367// ============================================================================
368
369/// Result from the simple `show_filter()` API
370#[derive(Debug)]
371pub enum SCPickerFilterOutcome {
372 /// User selected content - contains the filter to use with `SCStream`
373 Filter(SCContentFilter),
374 /// User cancelled the picker
375 Cancelled,
376 /// An error occurred
377 Error(String),
378}
379
380// ============================================================================
381// Main API: Returns SCPickerResult with metadata
382// ============================================================================
383
384/// Result from the main `show()` API - contains filter and content metadata
385///
386/// Provides access to:
387/// - The `SCContentFilter` for use with `SCStream`
388/// - Content dimensions and scale factor
389/// - The picked windows, displays, and applications for custom filter creation
390pub struct SCPickerResult {
391 ptr: *const c_void,
392}
393
394impl SCPickerResult {
395 /// Create from raw pointer (used by async API)
396 #[cfg(feature = "async")]
397 #[must_use]
398 pub(crate) fn from_ptr(ptr: *const c_void) -> Self {
399 Self { ptr }
400 }
401
402 /// Get the content filter for use with `SCStream::new()`
403 #[must_use]
404 pub fn filter(&self) -> SCContentFilter {
405 let filter_ptr = unsafe { crate::ffi::sc_picker_result_get_filter(self.ptr) };
406 SCContentFilter::from_picker_ptr(filter_ptr)
407 }
408
409 /// Get the content size in points (width, height)
410 #[must_use]
411 pub fn size(&self) -> (f64, f64) {
412 let mut x = 0.0;
413 let mut y = 0.0;
414 let mut width = 0.0;
415 let mut height = 0.0;
416 unsafe {
417 crate::ffi::sc_picker_result_get_content_rect(
418 self.ptr,
419 &raw mut x,
420 &raw mut y,
421 &raw mut width,
422 &raw mut height,
423 );
424 }
425 (width, height)
426 }
427
428 /// Get the content rect (x, y, width, height) in points
429 #[must_use]
430 pub fn rect(&self) -> (f64, f64, f64, f64) {
431 let mut x = 0.0;
432 let mut y = 0.0;
433 let mut width = 0.0;
434 let mut height = 0.0;
435 unsafe {
436 crate::ffi::sc_picker_result_get_content_rect(
437 self.ptr,
438 &raw mut x,
439 &raw mut y,
440 &raw mut width,
441 &raw mut height,
442 );
443 }
444 (x, y, width, height)
445 }
446
447 /// Get the point-to-pixel scale factor (typically 2.0 for Retina displays)
448 #[must_use]
449 pub fn scale(&self) -> f64 {
450 unsafe { crate::ffi::sc_picker_result_get_scale(self.ptr) }
451 }
452
453 /// Get the pixel dimensions (size * scale)
454 #[must_use]
455 pub fn pixel_size(&self) -> (u32, u32) {
456 let (w, h) = self.size();
457 let scale = self.scale();
458 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
459 let width = (w * scale) as u32;
460 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
461 let height = (h * scale) as u32;
462 (width, height)
463 }
464
465 /// Get the windows selected by the user
466 ///
467 /// Returns the picked windows that can be used to create a custom `SCContentFilter`.
468 ///
469 /// # Example
470 /// ```no_run
471 /// use screencapturekit::content_sharing_picker::*;
472 /// use screencapturekit::prelude::*;
473 ///
474 /// let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
475 /// SCContentSharingPicker::show(&config, |outcome| {
476 /// if let SCPickerOutcome::Picked(result) = outcome {
477 /// let windows = result.windows();
478 /// if let Some(window) = windows.first() {
479 /// // Create custom filter with a picked window
480 /// let filter = SCContentFilter::create()
481 /// .with_window(window)
482 /// .build().expect("failed to build content filter");
483 /// }
484 /// }
485 /// });
486 /// ```
487 #[must_use]
488 pub fn windows(&self) -> Vec<crate::shareable_content::SCWindow> {
489 let count = unsafe { crate::ffi::sc_picker_result_get_windows_count(self.ptr) };
490 (0..count)
491 .filter_map(|i| {
492 let ptr = unsafe { crate::ffi::sc_picker_result_get_window_at(self.ptr, i) };
493 unsafe { crate::shareable_content::SCWindow::from_retained_ptr(ptr) }
494 })
495 .collect()
496 }
497
498 /// Get the displays selected by the user
499 ///
500 /// Returns the picked displays that can be used to create a custom `SCContentFilter`.
501 ///
502 /// # Example
503 /// ```no_run
504 /// use screencapturekit::content_sharing_picker::*;
505 /// use screencapturekit::prelude::*;
506 ///
507 /// let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
508 /// SCContentSharingPicker::show(&config, |outcome| {
509 /// if let SCPickerOutcome::Picked(result) = outcome {
510 /// let displays = result.displays();
511 /// if let Some(display) = displays.first() {
512 /// // Create custom filter with the picked display
513 /// let filter = SCContentFilter::create()
514 /// .with_display(display)
515 /// .with_excluding_windows(&[])
516 /// .build().expect("failed to build content filter");
517 /// }
518 /// }
519 /// });
520 /// ```
521 #[must_use]
522 pub fn displays(&self) -> Vec<crate::shareable_content::SCDisplay> {
523 let count = unsafe { crate::ffi::sc_picker_result_get_displays_count(self.ptr) };
524 (0..count)
525 .filter_map(|i| {
526 let ptr = unsafe { crate::ffi::sc_picker_result_get_display_at(self.ptr, i) };
527 unsafe { crate::shareable_content::SCDisplay::from_retained_ptr(ptr) }
528 })
529 .collect()
530 }
531
532 /// Get the applications selected by the user
533 ///
534 /// Returns the picked applications that can be used to create a custom `SCContentFilter`.
535 #[must_use]
536 pub fn applications(&self) -> Vec<crate::shareable_content::SCRunningApplication> {
537 let count = unsafe { crate::ffi::sc_picker_result_get_applications_count(self.ptr) };
538 (0..count)
539 .filter_map(|i| {
540 let ptr = unsafe { crate::ffi::sc_picker_result_get_application_at(self.ptr, i) };
541 unsafe { crate::shareable_content::SCRunningApplication::from_retained_ptr(ptr) }
542 })
543 .collect()
544 }
545
546 /// Get the source type that was picked
547 ///
548 /// Returns information about what the user selected: window, display, or application.
549 ///
550 /// # Example
551 /// ```no_run
552 /// use screencapturekit::content_sharing_picker::*;
553 ///
554 /// fn example() {
555 /// let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
556 /// SCContentSharingPicker::show(&config, |outcome| {
557 /// if let SCPickerOutcome::Picked(result) = outcome {
558 /// match result.source() {
559 /// SCPickedSource::Window(title) => println!("[W] {}", title),
560 /// SCPickedSource::Display(id) => println!("[D] Display {}", id),
561 /// SCPickedSource::Application(name) => println!("[A] {}", name),
562 /// SCPickedSource::Unknown => println!("Unknown source"),
563 /// }
564 /// }
565 /// });
566 /// }
567 /// ```
568 #[must_use]
569 #[allow(clippy::option_if_let_else)]
570 pub fn source(&self) -> SCPickedSource {
571 if let Some(window) = self.windows().first() {
572 SCPickedSource::Window(window.title().unwrap_or_else(|| "Untitled".to_string()))
573 } else if let Some(display) = self.displays().first() {
574 SCPickedSource::Display(display.display_id())
575 } else if let Some(app) = self.applications().first() {
576 SCPickedSource::Application(app.application_name())
577 } else {
578 SCPickedSource::Unknown
579 }
580 }
581}
582
583crate::utils::retained::sc_retained!(
584 SCPickerResult,
585 field = ptr,
586 release = crate::ffi::sc_picker_result_release,
587);
588
589impl std::fmt::Debug for SCPickerResult {
590 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
591 let (w, h) = self.size();
592 let scale = self.scale();
593 f.debug_struct("SCPickerResult")
594 .field("size", &(w, h))
595 .field("scale", &scale)
596 .field("pixel_size", &self.pixel_size())
597 .finish()
598 }
599}
600
601/// Outcome from the main `show()` API
602#[derive(Debug)]
603pub enum SCPickerOutcome {
604 /// User selected content - contains result with filter and metadata
605 Picked(SCPickerResult),
606 /// User cancelled the picker
607 Cancelled,
608 /// An error occurred
609 Error(String),
610}
611
612/// Error returned by `SCContentSharingPicker` operations.
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614pub enum SCPickerConfigurationError {
615 /// The picker API is unavailable on this system.
616 Unavailable,
617 /// Picker configuration properties must be assigned on the process main
618 /// thread.
619 MainThreadRequired,
620}
621
622impl std::fmt::Display for SCPickerConfigurationError {
623 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
624 match self {
625 Self::Unavailable => f.write_str("content sharing picker is unavailable"),
626 Self::MainThreadRequired => {
627 f.write_str("content sharing picker configuration requires the main thread")
628 }
629 }
630 }
631}
632
633impl std::error::Error for SCPickerConfigurationError {}
634
635// ============================================================================
636// SCContentSharingPicker
637// ============================================================================
638
639/// System UI for selecting content to share
640///
641/// Available on macOS 14.0+
642///
643/// The picker requires user interaction and cannot block the calling thread.
644/// Use one of these approaches:
645///
646/// - **Callback-based**: `show()` / `show_filter()` - pass a callback closure
647/// - **Async/await**: `AsyncSCContentSharingPicker` from the `async_api` module
648///
649/// # Example (callback)
650/// ```no_run
651/// use screencapturekit::content_sharing_picker::*;
652///
653/// let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
654/// SCContentSharingPicker::show(&config, |outcome| {
655/// if let SCPickerOutcome::Picked(result) = outcome {
656/// let (width, height) = result.pixel_size();
657/// let filter = result.filter();
658/// // ... create stream
659/// }
660/// });
661/// ```
662///
663/// # Example (async)
664/// ```no_run
665/// use screencapturekit::async_api::AsyncSCContentSharingPicker;
666/// use screencapturekit::content_sharing_picker::*;
667///
668/// async fn example() {
669/// let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
670/// if let SCPickerOutcome::Picked(result) = AsyncSCContentSharingPicker::show(&config).await {
671/// let (width, height) = result.pixel_size();
672/// let filter = result.filter();
673/// // ... create stream
674/// }
675/// }
676/// ```
677#[derive(Debug)]
678pub struct SCContentSharingPicker;
679
680impl SCContentSharingPicker {
681 fn require_available() -> Result<(), SCPickerConfigurationError> {
682 if Self::is_available() {
683 Ok(())
684 } else {
685 Err(SCPickerConfigurationError::Unavailable)
686 }
687 }
688
689 /// Whether content-sharing picker APIs are available on this system.
690 #[must_use]
691 pub fn is_available() -> bool {
692 unsafe { crate::ffi::sc_content_sharing_picker_is_available() }
693 }
694
695 /// Show the picker UI with a callback for the result
696 ///
697 /// This is non-blocking - the callback is invoked when the user makes a selection
698 /// or cancels the picker.
699 ///
700 /// # Example
701 /// ```no_run
702 /// use screencapturekit::content_sharing_picker::*;
703 ///
704 /// let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
705 /// SCContentSharingPicker::show(&config, |outcome| {
706 /// match outcome {
707 /// SCPickerOutcome::Picked(result) => {
708 /// let (width, height) = result.pixel_size();
709 /// let filter = result.filter();
710 /// println!("Selected {}x{}", width, height);
711 /// }
712 /// SCPickerOutcome::Cancelled => println!("Cancelled"),
713 /// SCPickerOutcome::Error(e) => eprintln!("Error: {}", e),
714 /// }
715 /// });
716 /// ```
717 pub fn show<F>(config: &SCContentSharingPickerConfiguration, callback: F)
718 where
719 F: FnOnce(SCPickerOutcome) + Send + 'static,
720 {
721 let context = into_callback_context::<SCPickerOutcome, F>(callback);
722
723 unsafe {
724 crate::ffi::sc_content_sharing_picker_show_with_result(
725 config.as_ptr(),
726 picker_trampoline::<ResultDecoder>,
727 context,
728 );
729 }
730 }
731
732 /// Show the picker UI for an existing stream (to change source while capturing)
733 ///
734 /// Use this when you have an active `SCStream` and want to let the user
735 /// select a new content source. The callback receives the new filter
736 /// which can be used with `stream.update_content_filter()`.
737 ///
738 /// # Example
739 /// ```no_run
740 /// use screencapturekit::content_sharing_picker::*;
741 /// use screencapturekit::stream::SCStream;
742 /// use screencapturekit::stream::configuration::SCStreamConfiguration;
743 /// use screencapturekit::stream::content_filter::SCContentFilter;
744 /// use screencapturekit::shareable_content::SCShareableContent;
745 ///
746 /// fn example() -> Option<()> {
747 /// let content = SCShareableContent::get().ok()?;
748 /// let displays = content.displays();
749 /// let display = displays.first()?;
750 /// let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build().ok()?;
751 /// let stream_config = SCStreamConfiguration::new();
752 /// let stream = SCStream::new(&filter, &stream_config).ok()?;
753 ///
754 /// // When stream is active and user wants to change source
755 /// let config = SCContentSharingPickerConfiguration::new().ok()?;
756 /// SCContentSharingPicker::show_for_stream(&config, &stream, |outcome| {
757 /// if let SCPickerOutcome::Picked(result) = outcome {
758 /// // Use result.filter() with stream.update_content_filter()
759 /// let _ = result.filter();
760 /// }
761 /// });
762 /// Some(())
763 /// }
764 /// ```
765 pub fn show_for_stream<F>(
766 config: &SCContentSharingPickerConfiguration,
767 stream: &crate::stream::SCStream,
768 callback: F,
769 ) where
770 F: FnOnce(SCPickerOutcome) + Send + 'static,
771 {
772 let context = into_callback_context::<SCPickerOutcome, F>(callback);
773
774 unsafe {
775 crate::ffi::sc_content_sharing_picker_show_for_stream(
776 config.as_ptr(),
777 stream.as_ptr(),
778 picker_trampoline::<ResultDecoder>,
779 context,
780 );
781 }
782 }
783
784 /// Show the picker UI with a callback that receives just the filter
785 ///
786 /// This is the simple API - use when you just need the filter without metadata.
787 ///
788 /// # Example
789 /// ```no_run
790 /// use screencapturekit::content_sharing_picker::*;
791 ///
792 /// let config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
793 /// SCContentSharingPicker::show_filter(&config, |outcome| {
794 /// if let SCPickerFilterOutcome::Filter(filter) = outcome {
795 /// // Use filter with SCStream
796 /// }
797 /// });
798 /// ```
799 pub fn show_filter<F>(config: &SCContentSharingPickerConfiguration, callback: F)
800 where
801 F: FnOnce(SCPickerFilterOutcome) + Send + 'static,
802 {
803 let context = into_callback_context::<SCPickerFilterOutcome, F>(callback);
804
805 unsafe {
806 crate::ffi::sc_content_sharing_picker_show(
807 config.as_ptr(),
808 picker_trampoline::<FilterDecoder>,
809 context,
810 );
811 }
812 }
813
814 /// Show the picker UI with a specific content style
815 ///
816 /// Presents the picker pre-filtered to a specific content type.
817 ///
818 /// # Arguments
819 /// * `config` - The picker configuration
820 /// * `style` - The content style to show (Window, Display, Application)
821 /// * `callback` - Called with the picker result
822 pub fn show_using_style<F>(
823 config: &SCContentSharingPickerConfiguration,
824 style: crate::stream::content_filter::SCShareableContentStyle,
825 callback: F,
826 ) where
827 F: FnOnce(SCPickerOutcome) + Send + 'static,
828 {
829 let context = into_callback_context::<SCPickerOutcome, F>(callback);
830
831 unsafe {
832 crate::ffi::sc_content_sharing_picker_show_using_style(
833 config.as_ptr(),
834 style as i32,
835 picker_trampoline::<ResultDecoder>,
836 context,
837 );
838 }
839 }
840
841 /// Show the picker for an existing stream with a specific content style
842 ///
843 /// # Arguments
844 /// * `config` - The picker configuration
845 /// * `stream` - The stream to update
846 /// * `style` - The content style to show (Window, Display, Application)
847 /// * `callback` - Called with the picker result
848 pub fn show_for_stream_using_style<F>(
849 config: &SCContentSharingPickerConfiguration,
850 stream: &crate::stream::SCStream,
851 style: crate::stream::content_filter::SCShareableContentStyle,
852 callback: F,
853 ) where
854 F: FnOnce(SCPickerOutcome) + Send + 'static,
855 {
856 let context = into_callback_context::<SCPickerOutcome, F>(callback);
857
858 unsafe {
859 crate::ffi::sc_content_sharing_picker_show_for_stream_using_style(
860 config.as_ptr(),
861 stream.as_ptr(),
862 style as i32,
863 picker_trampoline::<ResultDecoder>,
864 context,
865 );
866 }
867 }
868
869 /// Set the maximum number of streams that can be created from the picker
870 ///
871 /// Pass 0 to allow unlimited streams.
872 #[allow(clippy::missing_errors_doc)]
873 pub fn set_maximum_stream_count(count: usize) -> Result<(), SCPickerConfigurationError> {
874 Self::require_available()?;
875 unsafe {
876 crate::ffi::sc_content_sharing_picker_set_maximum_stream_count(count);
877 }
878 Ok(())
879 }
880
881 /// Get the maximum number of streams allowed
882 ///
883 /// Returns 0 if unlimited streams are allowed.
884 pub fn maximum_stream_count() -> usize {
885 if !Self::is_available() {
886 return 0;
887 }
888 unsafe { crate::ffi::sc_content_sharing_picker_get_maximum_stream_count() }
889 }
890
891 /// Returns whether the shared content-sharing picker is currently
892 /// marked active.
893 ///
894 /// Apple requires `picker.isActive = true` before its UI can appear.
895 /// The various `show*()` trampolines on this type set it implicitly
896 /// before presenting, but this getter is useful for callers that
897 /// want to:
898 ///
899 /// * avoid double-presenting (skip a second `show()` while the first
900 /// picker session is still up),
901 /// * render UI affordances based on whether the picker is currently
902 /// visible to the user.
903 #[must_use]
904 pub fn is_active() -> bool {
905 if !Self::is_available() {
906 return false;
907 }
908 unsafe { crate::ffi::sc_content_sharing_picker_get_active() }
909 }
910
911 /// Mark the shared content-sharing picker active or inactive.
912 ///
913 /// Setting this to `false` hides the picker UI between sessions
914 /// (the recommended hygiene step after a long-running app finishes
915 /// using the picker — leaving it active leaves the system-level
916 /// Control Center entry in a "ready to share" state).
917 ///
918 /// Setting to `true` is required before `present*()` can surface
919 /// the picker; the `show*()` trampolines do this for you. Set it
920 /// manually only if you want to opt into the picker UI without
921 /// immediately presenting it.
922 #[allow(clippy::missing_errors_doc)]
923 pub fn set_active(active: bool) -> Result<(), SCPickerConfigurationError> {
924 Self::require_available()?;
925 unsafe { crate::ffi::sc_content_sharing_picker_set_active(active) };
926 Ok(())
927 }
928
929 /// Deactivate the picker and undo any activation-policy promotion the
930 /// bridge performed on behalf of a non-UI (`.prohibited`) host process.
931 ///
932 /// Presenting `SCContentSharingPicker` requires the process to be a
933 /// regular, Dock-visible app. Pure-Rust hosts usually are not, so the
934 /// bridge temporarily promotes them; the promotion is reference counted
935 /// and unwound automatically when each one-shot `show*()` resolves. Call
936 /// this after you are done with a *long-lived* observer session to drop
937 /// the promotion immediately and clear the Control Center "ready to
938 /// share" indicator.
939 ///
940 /// Registered observers are **not** removed — drop their
941 /// [`SCPickerSubscription`] for that.
942 pub fn deactivate() {
943 if !Self::is_available() {
944 return;
945 }
946 unsafe { crate::ffi::sc_content_sharing_picker_deactivate() }
947 }
948
949 // ------------------------------------------------------------------
950 // Standalone configuration operations
951 // ------------------------------------------------------------------
952
953 /// Read the picker's process-wide default configuration.
954 ///
955 /// Equivalent to Apple's `SCContentSharingPicker.shared.defaultConfiguration`.
956 /// This is the same value returned by
957 /// [`SCContentSharingPickerConfiguration::default_from_system`].
958 ///
959 /// # Errors
960 ///
961 /// Returns [`SCError::FeatureNotAvailable`] when run on macOS older than
962 /// 14.0.
963 pub fn default_configuration() -> Result<SCContentSharingPickerConfiguration, SCError> {
964 SCContentSharingPickerConfiguration::default_from_system()
965 }
966
967 /// Assign the picker's process-wide default configuration.
968 ///
969 /// Apple's `SCContentSharingPicker.defaultConfiguration` is read-write;
970 /// previously this crate could only set it as a side effect of calling a
971 /// `show*()` helper. Setting it explicitly is what you want when driving
972 /// the picker with a persistent observer plus [`Self::present`].
973 ///
974 /// # Examples
975 ///
976 /// ```no_run
977 /// use screencapturekit::content_sharing_picker::*;
978 ///
979 /// let mut config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
980 /// config.set_allows_changing_selected_content(true);
981 /// SCContentSharingPicker::set_default_configuration(&config)
982 /// .expect("call from the process main thread");
983 /// ```
984 ///
985 /// The assignment is complete when this method returns, so an immediate
986 /// call to [`Self::default_configuration`] observes the new value.
987 ///
988 /// # Errors
989 ///
990 /// Returns [`SCPickerConfigurationError::Unavailable`] when the API is
991 /// unavailable, or [`SCPickerConfigurationError::MainThreadRequired`]
992 /// when called from any other thread. Failed calls do not enqueue a later
993 /// mutation.
994 pub fn set_default_configuration(
995 config: &SCContentSharingPickerConfiguration,
996 ) -> Result<(), SCPickerConfigurationError> {
997 if !Self::is_available() {
998 return Err(SCPickerConfigurationError::Unavailable);
999 }
1000 if unsafe {
1001 crate::ffi::sc_content_sharing_picker_set_default_configuration(config.as_ptr())
1002 } {
1003 Ok(())
1004 } else {
1005 Err(SCPickerConfigurationError::MainThreadRequired)
1006 }
1007 }
1008
1009 /// Assign a picker configuration scoped to a single stream, mirroring
1010 /// Apple's `setConfiguration(_:for:)`.
1011 ///
1012 /// Pass `None` to clear the stream-specific configuration and fall back to
1013 /// the process-wide default.
1014 ///
1015 /// The assignment is complete when this method returns.
1016 ///
1017 /// # Errors
1018 ///
1019 /// Returns [`SCPickerConfigurationError::Unavailable`] when the API is
1020 /// unavailable, or [`SCPickerConfigurationError::MainThreadRequired`]
1021 /// when called from any other thread. Failed calls do not enqueue a later
1022 /// mutation.
1023 pub fn set_configuration_for_stream(
1024 config: Option<&SCContentSharingPickerConfiguration>,
1025 stream: &crate::stream::SCStream,
1026 ) -> Result<(), SCPickerConfigurationError> {
1027 if !Self::is_available() {
1028 return Err(SCPickerConfigurationError::Unavailable);
1029 }
1030 let config_ptr = config.map_or(
1031 std::ptr::null(),
1032 SCContentSharingPickerConfiguration::as_ptr,
1033 );
1034 if unsafe {
1035 crate::ffi::sc_content_sharing_picker_set_configuration_for_stream(
1036 config_ptr,
1037 stream.as_ptr(),
1038 )
1039 } {
1040 Ok(())
1041 } else {
1042 Err(SCPickerConfigurationError::MainThreadRequired)
1043 }
1044 }
1045
1046 // ------------------------------------------------------------------
1047 // Persistent observers
1048 // ------------------------------------------------------------------
1049
1050 /// Register a **repeating** observer that receives every picker event for
1051 /// as long as the returned subscription is alive.
1052 ///
1053 /// This is the API to use with
1054 /// [`SCContentSharingPickerConfiguration::set_allows_changing_selected_content`]:
1055 /// Apple re-invokes `contentSharingPicker(_:didUpdateWith:for:)` each time
1056 /// the user re-picks during an active share, and the one-shot
1057 /// [`Self::show`] family deliberately latches after the first event.
1058 ///
1059 /// The subscription unregisters on drop, so bind it to a variable that
1060 /// lives as long as you want events (use [`SCPickerSubscription::detach`]
1061 /// to keep it for the remainder of the process).
1062 ///
1063 /// Pair this with [`Self::present`] / [`Self::present_for_stream`] to
1064 /// surface the UI.
1065 ///
1066 /// Apple marks `SCContentSharingPicker` as `@MainActor`. Call this on the
1067 /// process main thread, or while an `AppKit` main run loop is active so the
1068 /// bridge can synchronously hop to it.
1069 ///
1070 /// # Errors
1071 ///
1072 /// Returns [`SCPickerConfigurationError::Unavailable`] below macOS 14.0,
1073 /// and [`SCPickerConfigurationError::MainThreadRequired`] when called off
1074 /// the main thread without an active main run loop.
1075 ///
1076 /// # Examples
1077 ///
1078 /// ```no_run
1079 /// use screencapturekit::content_sharing_picker::*;
1080 ///
1081 /// let mut config = SCContentSharingPickerConfiguration::new().expect("create picker configuration");
1082 /// config.set_allows_changing_selected_content(true);
1083 /// SCContentSharingPicker::set_default_configuration(&config)
1084 /// .expect("call from the process main thread");
1085 ///
1086 /// let subscription = SCContentSharingPicker::add_observer(|event| match event {
1087 /// SCPickerEvent::Updated { result, stream } => {
1088 /// // Fires again every time the user changes their selection.
1089 /// let _filter = result.filter();
1090 /// let _existing_stream = stream;
1091 /// }
1092 /// SCPickerEvent::Cancelled { .. } => println!("cancelled"),
1093 /// SCPickerEvent::Failed(err) => eprintln!("picker failed: {err}"),
1094 /// }).expect("register picker observer");
1095 ///
1096 /// SCContentSharingPicker::present().expect("present picker");
1097 /// // ... keep `subscription` alive for as long as you want updates ...
1098 /// drop(subscription);
1099 /// ```
1100 pub fn add_observer<F>(handler: F) -> Result<SCPickerSubscription, SCPickerConfigurationError>
1101 where
1102 F: Fn(SCPickerEvent) + Send + Sync + 'static,
1103 {
1104 Self::require_available()?;
1105 let (context, active) = SCPickerObserverContext::into_raw(handler);
1106 let token = unsafe {
1107 crate::ffi::sc_content_sharing_picker_add_observer(
1108 observer_trampoline,
1109 observer_context_release,
1110 context,
1111 )
1112 };
1113
1114 if token == 0 {
1115 observer_context_release(context);
1116 return Err(SCPickerConfigurationError::MainThreadRequired);
1117 }
1118
1119 Ok(SCPickerSubscription { token, active })
1120 }
1121
1122 /// Remove every repeating observer registered through
1123 /// [`Self::add_observer`], regardless of which subscriptions are still
1124 /// alive. Returns how many were removed.
1125 ///
1126 /// Dropping the corresponding [`SCPickerSubscription`] afterwards is
1127 /// harmless — removal is idempotent.
1128 pub fn remove_all_observers() -> usize {
1129 if !Self::is_available() {
1130 return 0;
1131 }
1132 unsafe { crate::ffi::sc_content_sharing_picker_remove_all_observers() }
1133 }
1134
1135 // ------------------------------------------------------------------
1136 // Standalone presentation
1137 // ------------------------------------------------------------------
1138
1139 /// Present the picker without a content-style hint.
1140 ///
1141 /// Use with [`Self::add_observer`]; the one-shot [`Self::show`] family
1142 /// presents for you.
1143 #[allow(clippy::missing_errors_doc)]
1144 pub fn present() -> Result<(), SCPickerConfigurationError> {
1145 Self::require_available()?;
1146 unsafe { crate::ffi::sc_content_sharing_picker_present(-1) };
1147 Ok(())
1148 }
1149
1150 /// Present the picker preselecting a content style.
1151 #[allow(clippy::missing_errors_doc)]
1152 pub fn present_using_style(
1153 style: SCShareableContentStyle,
1154 ) -> Result<(), SCPickerConfigurationError> {
1155 Self::require_available()?;
1156 unsafe { crate::ffi::sc_content_sharing_picker_present(style as i32) };
1157 Ok(())
1158 }
1159
1160 /// Present the picker targeting an existing stream, so the user can swap
1161 /// the shared source mid-capture.
1162 #[allow(clippy::missing_errors_doc)]
1163 pub fn present_for_stream(
1164 stream: &crate::stream::SCStream,
1165 ) -> Result<(), SCPickerConfigurationError> {
1166 Self::require_available()?;
1167 unsafe { crate::ffi::sc_content_sharing_picker_present_for_stream(stream.as_ptr(), -1) };
1168 Ok(())
1169 }
1170
1171 /// Present the picker targeting an existing stream, preselecting a style.
1172 #[allow(clippy::missing_errors_doc)]
1173 pub fn present_for_stream_using_style(
1174 stream: &crate::stream::SCStream,
1175 style: SCShareableContentStyle,
1176 ) -> Result<(), SCPickerConfigurationError> {
1177 Self::require_available()?;
1178 unsafe {
1179 crate::ffi::sc_content_sharing_picker_present_for_stream(stream.as_ptr(), style as i32);
1180 }
1181 Ok(())
1182 }
1183}
1184
1185// ============================================================================
1186// Persistent observer: events, subscription handle, context, trampoline
1187// ============================================================================
1188
1189/// A single event delivered to a repeating observer registered with
1190/// [`SCContentSharingPicker::add_observer`].
1191///
1192/// Unlike [`SCPickerOutcome`], which resolves a one-shot `show*()` call,
1193/// these arrive as many times as the user interacts with the picker.
1194#[derive(Debug)]
1195pub enum SCPickerEvent {
1196 /// The user selected (or re-selected) content. Mirrors Apple's
1197 /// `contentSharingPicker(_:didUpdateWith:for:)`. `stream` identifies an
1198 /// existing stream being updated; `None` means the user made a new
1199 /// selection rather than replacing a stream's source.
1200 Updated {
1201 /// Selected filter and metadata.
1202 result: SCPickerResult,
1203 /// Non-owning identity of the stream being updated.
1204 stream: Option<StreamIdentity>,
1205 },
1206 /// The user dismissed the picker. Mirrors
1207 /// `contentSharingPicker(_:didCancelFor:)`.
1208 Cancelled {
1209 /// Non-owning identity of the stream whose update was cancelled.
1210 stream: Option<StreamIdentity>,
1211 },
1212 /// The picker failed to start. Mirrors
1213 /// `contentSharingPickerStartDidFailWithError(_:)`.
1214 Failed(String),
1215}
1216
1217/// Handle representing a live repeating-observer registration.
1218///
1219/// The observer is removed when this value is dropped. Call
1220/// [`Self::detach`] to keep the observer alive for the rest of the process.
1221#[derive(Debug)]
1222#[must_use = "the observer is removed as soon as the subscription is dropped"]
1223pub struct SCPickerSubscription {
1224 token: i64,
1225 active: std::sync::Arc<AtomicBool>,
1226}
1227
1228impl SCPickerSubscription {
1229 /// Opaque identifier for this registration. Non-zero when registration
1230 /// succeeded.
1231 #[must_use]
1232 pub const fn token(&self) -> i64 {
1233 self.token
1234 }
1235
1236 /// Whether this subscription refers to a live registration.
1237 #[must_use]
1238 pub fn is_active(&self) -> bool {
1239 self.active.load(Ordering::Acquire)
1240 }
1241
1242 /// Remove the observer now instead of waiting for the drop.
1243 ///
1244 /// Returns `true` if a live observer was removed.
1245 pub fn unsubscribe(mut self) -> bool {
1246 self.remove()
1247 }
1248
1249 /// Give up ownership without removing the observer, keeping it registered
1250 /// for the remainder of the process.
1251 ///
1252 /// Useful for "install once at startup" wiring where there is no natural
1253 /// owner for the handle. The registration can still be torn down with
1254 /// [`SCContentSharingPicker::remove_all_observers`].
1255 pub fn detach(mut self) {
1256 self.token = 0;
1257 }
1258
1259 fn remove(&mut self) -> bool {
1260 if self.token == 0 || !self.is_active() {
1261 self.token = 0;
1262 return false;
1263 }
1264 let removed = unsafe { crate::ffi::sc_content_sharing_picker_remove_observer(self.token) };
1265 self.token = 0;
1266 if !removed {
1267 self.active.store(false, Ordering::Release);
1268 }
1269 removed
1270 }
1271}
1272
1273impl Drop for SCPickerSubscription {
1274 fn drop(&mut self) {
1275 self.remove();
1276 }
1277}
1278
1279struct SCPickerObserverContext {
1280 /// Cleared on unsubscribe so a callback already in flight is dropped
1281 /// rather than delivered after the user asked to stop listening.
1282 active: std::sync::Arc<AtomicBool>,
1283 handler: Box<dyn Fn(SCPickerEvent) + Send + Sync>,
1284}
1285
1286impl SCPickerObserverContext {
1287 fn into_raw<F>(handler: F) -> (*mut c_void, std::sync::Arc<AtomicBool>)
1288 where
1289 F: Fn(SCPickerEvent) + Send + Sync + 'static,
1290 {
1291 let active = std::sync::Arc::new(AtomicBool::new(true));
1292 let context = std::sync::Arc::new(Self {
1293 active: std::sync::Arc::clone(&active),
1294 handler: Box::new(handler),
1295 });
1296 let mut registry = PICKER_OBSERVER_CONTEXTS
1297 .lock()
1298 .unwrap_or_else(PoisonError::into_inner);
1299 let contexts = registry.get_or_insert_with(HashMap::new);
1300 let id = loop {
1301 let id = NEXT_PICKER_OBSERVER_CONTEXT_ID.fetch_add(1, Ordering::Relaxed);
1302 if id != 0 && !contexts.contains_key(&id) {
1303 break id;
1304 }
1305 };
1306 contexts.insert(id, context);
1307 drop(registry);
1308 (id as *mut c_void, active)
1309 }
1310}
1311
1312static NEXT_PICKER_OBSERVER_CONTEXT_ID: AtomicUsize = AtomicUsize::new(1);
1313static PICKER_OBSERVER_CONTEXTS: Mutex<
1314 Option<HashMap<usize, std::sync::Arc<SCPickerObserverContext>>>,
1315> = Mutex::new(None);
1316
1317fn picker_observer_context(
1318 context: *mut c_void,
1319) -> Option<std::sync::Arc<SCPickerObserverContext>> {
1320 let id = context as usize;
1321 if id == 0 {
1322 return None;
1323 }
1324 PICKER_OBSERVER_CONTEXTS
1325 .lock()
1326 .unwrap_or_else(PoisonError::into_inner)
1327 .as_ref()?
1328 .get(&id)
1329 .cloned()
1330}
1331
1332extern "C" fn observer_context_release(context: *mut c_void) {
1333 crate::utils::panic_safe::catch_user_panic("picker observer context release", || {
1334 let id = context as usize;
1335 if id == 0 {
1336 return;
1337 }
1338 let removed = {
1339 let mut contexts = PICKER_OBSERVER_CONTEXTS
1340 .lock()
1341 .unwrap_or_else(PoisonError::into_inner);
1342 contexts.as_mut().and_then(|contexts| contexts.remove(&id))
1343 };
1344 if let Some(context) = removed {
1345 context.active.store(false, Ordering::Release);
1346 }
1347 });
1348}
1349
1350/// Trampoline for every repeating-observer event.
1351///
1352/// `event` follows the Swift bridge contract: 1 = updated (with a result
1353/// pointer), 0 = cancelled, anything else = start failure (with a message).
1354extern "C" fn observer_trampoline(
1355 event: i32,
1356 result_ptr: *const c_void,
1357 message: *const i8,
1358 stream_ptr: *const c_void,
1359 context: *mut c_void,
1360) {
1361 // The whole body sits inside the barrier: the registry lookup and the
1362 // message decoding both allocate, and an unwind out of an `extern "C"`
1363 // function is undefined behaviour on this crate's MSRV.
1364 crate::utils::panic_safe::catch_user_panic("picker observer callback", move || {
1365 let Some(context) = picker_observer_context(context) else {
1366 if !result_ptr.is_null() {
1367 unsafe { crate::ffi::sc_picker_result_release(result_ptr) };
1368 }
1369 return;
1370 };
1371
1372 if !context.active.load(Ordering::Acquire) {
1373 // Unsubscribed while this callback was in flight; drop the result
1374 // rather than delivering it. Release the retained result first.
1375 if !result_ptr.is_null() {
1376 unsafe { crate::ffi::sc_picker_result_release(result_ptr) };
1377 }
1378 return;
1379 }
1380
1381 let stream = StreamIdentity::from_ptr(stream_ptr);
1382 let decoded = match event {
1383 1 if !result_ptr.is_null() => SCPickerEvent::Updated {
1384 result: SCPickerResult { ptr: result_ptr },
1385 stream,
1386 },
1387 1 => SCPickerEvent::Failed("picker delivered an update without a result".to_string()),
1388 0 => SCPickerEvent::Cancelled { stream },
1389 _ => {
1390 let text = if message.is_null() {
1391 "Content sharing picker failed to start".to_string()
1392 } else {
1393 // SAFETY: Swift passes a NUL-terminated UTF-8 buffer that is
1394 // valid for the duration of this call.
1395 unsafe { std::ffi::CStr::from_ptr(message) }
1396 .to_string_lossy()
1397 .into_owned()
1398 };
1399 SCPickerEvent::Failed(text)
1400 }
1401 };
1402
1403 (context.handler)(decoded);
1404 });
1405}
1406
1407// ============================================================================
1408// One-shot callback context + trampoline (shared by all `show*()` methods)
1409// ============================================================================
1410
1411/// Context owned by the one-shot callback registry.
1412struct PickerCallbackContext<O> {
1413 closure: Box<dyn FnOnce(O) + Send>,
1414}
1415
1416static NEXT_PICKER_CALLBACK_ID: AtomicUsize = AtomicUsize::new(1);
1417static PICKER_CALLBACKS: Mutex<Option<HashMap<usize, Box<dyn Any + Send>>>> = Mutex::new(None);
1418
1419/// Store a user closure and hand Swift a token that is never dereferenced.
1420#[allow(clippy::significant_drop_tightening)]
1421fn into_callback_context<O, F>(callback: F) -> *mut c_void
1422where
1423 O: 'static,
1424 F: FnOnce(O) + Send + 'static,
1425{
1426 let context = Box::new(PickerCallbackContext {
1427 closure: Box::new(callback),
1428 });
1429 let mut callbacks = PICKER_CALLBACKS
1430 .lock()
1431 .unwrap_or_else(PoisonError::into_inner);
1432 let callbacks = callbacks.get_or_insert_with(HashMap::new);
1433 loop {
1434 let id = NEXT_PICKER_CALLBACK_ID.fetch_add(1, Ordering::Relaxed);
1435 if id != 0 && !callbacks.contains_key(&id) {
1436 callbacks.insert(id, context);
1437 return id as *mut c_void;
1438 }
1439 }
1440}
1441
1442fn take_callback_context<O: 'static>(
1443 context: *mut c_void,
1444) -> Option<Box<PickerCallbackContext<O>>> {
1445 let id = context as usize;
1446 if id == 0 {
1447 return None;
1448 }
1449 let entry = PICKER_CALLBACKS
1450 .lock()
1451 .unwrap_or_else(PoisonError::into_inner)
1452 .as_mut()?
1453 .remove(&id)?;
1454 entry.downcast::<PickerCallbackContext<O>>().ok()
1455}
1456
1457/// Decodes the `(code, ptr)` pair from the Swift bridge into a typed outcome.
1458///
1459/// Implemented by zero-sized marker types so a single generic trampoline can
1460/// serve both the result-bearing and filter-only APIs while keeping the FFI
1461/// signature identical.
1462trait PickerDecode {
1463 type Outcome: 'static;
1464 fn decode(code: i32, ptr: *const c_void) -> Self::Outcome;
1465 unsafe fn release(ptr: *const c_void);
1466}
1467
1468struct ResultDecoder;
1469impl PickerDecode for ResultDecoder {
1470 type Outcome = SCPickerOutcome;
1471 fn decode(code: i32, ptr: *const c_void) -> SCPickerOutcome {
1472 match code {
1473 1 if !ptr.is_null() => SCPickerOutcome::Picked(SCPickerResult { ptr }),
1474 0 => SCPickerOutcome::Cancelled,
1475 _ => SCPickerOutcome::Error("Picker failed".to_string()),
1476 }
1477 }
1478
1479 unsafe fn release(ptr: *const c_void) {
1480 if !ptr.is_null() {
1481 unsafe { crate::ffi::sc_picker_result_release(ptr) };
1482 }
1483 }
1484}
1485
1486struct FilterDecoder;
1487impl PickerDecode for FilterDecoder {
1488 type Outcome = SCPickerFilterOutcome;
1489 fn decode(code: i32, ptr: *const c_void) -> SCPickerFilterOutcome {
1490 match code {
1491 1 if !ptr.is_null() => {
1492 SCPickerFilterOutcome::Filter(SCContentFilter::from_picker_ptr(ptr))
1493 }
1494 0 => SCPickerFilterOutcome::Cancelled,
1495 _ => SCPickerFilterOutcome::Error("Picker failed".to_string()),
1496 }
1497 }
1498
1499 unsafe fn release(ptr: *const c_void) {
1500 if !ptr.is_null() {
1501 unsafe { crate::ffi::sc_content_filter_release(ptr) };
1502 }
1503 }
1504}
1505
1506/// Single trampoline for every picker `show*()` callback.
1507///
1508/// `code` follows the Swift bridge contract (1 = picked, 0 = cancelled,
1509/// anything else = error). A `code` of 0 is also produced by the Swift
1510/// replacement path when a pending observer is superseded by a newer
1511/// `show*()`, so a replaced picker resolves as `Cancelled` rather than
1512/// leaking its context.
1513///
1514/// The registry entry is removed exactly once. Duplicate or late callbacks
1515/// find no entry, so they cannot dereference freed memory.
1516extern "C" fn picker_trampoline<D: PickerDecode>(
1517 code: i32,
1518 ptr: *const c_void,
1519 context: *mut c_void,
1520) {
1521 crate::utils::panic_safe::catch_user_panic("picker callback", move || {
1522 let Some(context) = take_callback_context::<D::Outcome>(context) else {
1523 unsafe { D::release(ptr) };
1524 return;
1525 };
1526 let outcome = D::decode(code, ptr);
1527 (context.closure)(outcome);
1528 });
1529}
1530
1531// SAFETY: the wrapper owns its Swift box exclusively — `Clone` copies the box
1532// rather than retaining it, and no constructor hands out a second handle to the
1533// same allocation. Mutation therefore only happens through `&mut self`, which
1534// Rust already makes exclusive, and the `&self` methods are pure getters. The
1535// box itself is a Swift class, so the refcount traffic that `Drop` performs is
1536// atomic.
1537unsafe impl Send for SCContentSharingPickerConfiguration {}
1538unsafe impl Sync for SCContentSharingPickerConfiguration {}
1539// SAFETY: `SCPickerResult` holds retained Objective-C objects whose reference
1540// counting is atomic; it is safe to send between and share across threads.
1541unsafe impl Send for SCPickerResult {}
1542unsafe impl Sync for SCPickerResult {}
1543
1544#[cfg(test)]
1545mod tests {
1546 use super::*;
1547 use std::sync::atomic::{AtomicUsize, Ordering};
1548 use std::sync::Arc;
1549
1550 #[test]
1551 fn duplicate_one_shot_callback_is_ignored_after_context_drop() {
1552 let calls = Arc::new(AtomicUsize::new(0));
1553 let observed = Arc::clone(&calls);
1554 let context = into_callback_context::<SCPickerFilterOutcome, _>(move |_| {
1555 observed.fetch_add(1, Ordering::SeqCst);
1556 });
1557
1558 picker_trampoline::<FilterDecoder>(0, std::ptr::null(), context);
1559 picker_trampoline::<FilterDecoder>(0, std::ptr::null(), context);
1560
1561 assert_eq!(calls.load(Ordering::SeqCst), 1);
1562 }
1563
1564 #[test]
1565 fn released_repeating_observer_ignores_late_callback() {
1566 let calls = Arc::new(AtomicUsize::new(0));
1567 let observed = Arc::clone(&calls);
1568 let (context, active) = SCPickerObserverContext::into_raw(move |_| {
1569 observed.fetch_add(1, Ordering::SeqCst);
1570 });
1571
1572 observer_context_release(context);
1573 observer_trampoline(
1574 0,
1575 std::ptr::null(),
1576 std::ptr::null(),
1577 std::ptr::null(),
1578 context,
1579 );
1580
1581 assert_eq!(calls.load(Ordering::SeqCst), 0);
1582 assert!(!active.load(Ordering::Acquire));
1583 }
1584
1585 #[test]
1586 fn repeating_observer_preserves_stream_identity() {
1587 let observed = Arc::new(Mutex::new(None));
1588 let output = Arc::clone(&observed);
1589 let (context, _) = SCPickerObserverContext::into_raw(move |event| {
1590 if let SCPickerEvent::Cancelled { stream } = event {
1591 *output.lock().unwrap_or_else(PoisonError::into_inner) = stream;
1592 }
1593 });
1594 let stream_ptr = std::ptr::NonNull::<c_void>::dangling()
1595 .as_ptr()
1596 .cast_const();
1597
1598 observer_trampoline(0, std::ptr::null(), std::ptr::null(), stream_ptr, context);
1599 observer_context_release(context);
1600
1601 assert_eq!(
1602 *observed.lock().unwrap_or_else(PoisonError::into_inner),
1603 StreamIdentity::from_ptr(stream_ptr)
1604 );
1605 }
1606}