Skip to main content

screencapturekit/stream/
content_filter.rs

1//! Content filter for `ScreenCaptureKit` streams
2//!
3//! This module provides a wrapper around `SCContentFilter` that uses the Swift bridge.
4//!
5//! # Examples
6//!
7//! ```no_run
8//! use screencapturekit::shareable_content::SCShareableContent;
9//! use screencapturekit::stream::content_filter::SCContentFilter;
10//!
11//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
12//! let content = SCShareableContent::get()?;
13//! let display = &content.displays()[0];
14//!
15//! // Capture entire display
16//! let filter = SCContentFilter::create()
17//!     .with_display(display)
18//!     .with_excluding_windows(&[])
19//!     .build();
20//! # Ok(())
21//! # }
22//! ```
23
24use std::ffi::c_void;
25use std::fmt;
26
27#[cfg(feature = "macos_14_2")]
28use crate::cg::CGRect;
29use crate::{
30    error::{SCError, SCResult},
31    ffi,
32    shareable_content::{SCDisplay, SCRunningApplication, SCWindow},
33};
34
35/// Content filter for `ScreenCaptureKit` streams
36///
37/// Defines what content to capture (displays, windows, or applications).
38///
39/// # Examples
40///
41/// ```no_run
42/// use screencapturekit::shareable_content::SCShareableContent;
43/// use screencapturekit::stream::content_filter::SCContentFilter;
44///
45/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
46/// let content = SCShareableContent::get()?;
47/// let display = &content.displays()[0];
48///
49/// // Capture entire display
50/// let filter = SCContentFilter::create()
51///     .with_display(display)
52///     .with_excluding_windows(&[])
53///     .build();
54///
55/// // Or capture a specific window
56/// let window = &content.windows()[0];
57/// let filter = SCContentFilter::create()
58///     .with_window(window)
59///     .build();
60/// # Ok(())
61/// # }
62/// ```
63pub struct SCContentFilter(*const c_void);
64
65impl PartialEq for SCContentFilter {
66    fn eq(&self, other: &Self) -> bool {
67        self.0 == other.0
68    }
69}
70
71impl Eq for SCContentFilter {}
72
73impl std::hash::Hash for SCContentFilter {
74    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
75        self.0.hash(state);
76    }
77}
78
79// Note: We intentionally do NOT implement Default for SCContentFilter.
80// A null filter would cause panics/crashes when used with SCStream.
81// Users should always use SCContentFilter::create() to create valid filters.
82
83impl SCContentFilter {
84    /// Creates a content filter builder
85    ///
86    /// # Examples
87    ///
88    /// ```no_run
89    /// use screencapturekit::prelude::*;
90    ///
91    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
92    /// let content = SCShareableContent::get()?;
93    /// let display = &content.displays()[0];
94    ///
95    /// let filter = SCContentFilter::create()
96    ///     .with_display(display)
97    ///     .with_excluding_windows(&[])
98    ///     .build();
99    /// # Ok(())
100    /// # }
101    /// ```
102    #[must_use]
103    pub fn create() -> SCContentFilterBuilder {
104        SCContentFilterBuilder::new()
105    }
106
107    /// Creates a content filter from a picker-returned pointer
108    ///
109    /// This is used internally when the content sharing picker returns a filter.
110    #[cfg(feature = "macos_14_0")]
111    pub(crate) fn from_picker_ptr(ptr: *const c_void) -> Self {
112        Self(ptr)
113    }
114
115    /// Returns the raw pointer to the content filter
116    pub(crate) fn as_ptr(&self) -> *const c_void {
117        self.0
118    }
119
120    /// Sets the content rectangle for this filter (macOS 14.2+)
121    ///
122    /// Specifies the rectangle within the content filter to capture.
123    #[cfg(feature = "macos_14_2")]
124    #[must_use]
125    pub fn set_content_rect(self, rect: CGRect) -> Self {
126        unsafe {
127            ffi::sc_content_filter_set_content_rect(
128                self.0,
129                rect.origin.x,
130                rect.origin.y,
131                rect.size.width,
132                rect.size.height,
133            );
134        }
135        self
136    }
137
138    /// Gets the content rectangle for this filter (macOS 14.2+)
139    #[cfg(feature = "macos_14_2")]
140    pub fn content_rect(&self) -> CGRect {
141        unsafe {
142            let mut x = 0.0;
143            let mut y = 0.0;
144            let mut width = 0.0;
145            let mut height = 0.0;
146            ffi::sc_content_filter_get_content_rect(
147                self.0,
148                &mut x,
149                &mut y,
150                &mut width,
151                &mut height,
152            );
153            CGRect::new(x, y, width, height)
154        }
155    }
156
157    /// Get the content style (macOS 14.0+)
158    ///
159    /// Returns the type of content being captured (window, display, application, or none).
160    #[cfg(feature = "macos_14_0")]
161    pub fn style(&self) -> SCShareableContentStyle {
162        let value = unsafe { ffi::sc_content_filter_get_style(self.0) };
163        SCShareableContentStyle::from(value)
164    }
165
166    /// Get the stream type (macOS 14.0+)
167    ///
168    /// Returns whether this filter captures a window or a display.
169    #[cfg(feature = "macos_14_0")]
170    pub fn stream_type(&self) -> SCStreamType {
171        let value = unsafe { ffi::sc_content_filter_get_stream_type(self.0) };
172        SCStreamType::from(value)
173    }
174
175    /// Get the point-to-pixel scale factor (macOS 14.0+)
176    ///
177    /// Returns the scaling factor used to convert points to pixels.
178    /// Typically 2.0 for Retina displays.
179    #[cfg(feature = "macos_14_0")]
180    pub fn point_pixel_scale(&self) -> f32 {
181        unsafe { ffi::sc_content_filter_get_point_pixel_scale(self.0) }
182    }
183
184    /// Include the menu bar in capture (macOS 14.2+)
185    ///
186    /// When set to `true`, the menu bar is included in display capture.
187    /// This property has no effect for window filters.
188    #[cfg(feature = "macos_14_2")]
189    pub fn set_include_menu_bar(&mut self, include: bool) {
190        unsafe {
191            ffi::sc_content_filter_set_include_menu_bar(self.0, include);
192        }
193    }
194
195    /// Check if menu bar is included in capture (macOS 14.2+)
196    #[cfg(feature = "macos_14_2")]
197    pub fn include_menu_bar(&self) -> bool {
198        unsafe { ffi::sc_content_filter_get_include_menu_bar(self.0) }
199    }
200
201    /// Get included displays (macOS 15.2+)
202    ///
203    /// Returns the displays currently included in this filter.
204    #[cfg(feature = "macos_15_2")]
205    pub fn included_displays(&self) -> Vec<SCDisplay> {
206        let count = unsafe { ffi::sc_content_filter_get_included_displays_count(self.0) };
207        if count <= 0 {
208            return Vec::new();
209        }
210        #[allow(clippy::cast_sign_loss)]
211        (0..count as usize)
212            .filter_map(|i| {
213                #[allow(clippy::cast_possible_wrap)]
214                let ptr =
215                    unsafe { ffi::sc_content_filter_get_included_display_at(self.0, i as isize) };
216                unsafe { SCDisplay::from_retained_ptr(ptr) }
217            })
218            .collect()
219    }
220
221    /// Get included windows (macOS 15.2+)
222    ///
223    /// Returns the windows currently included in this filter.
224    #[cfg(feature = "macos_15_2")]
225    pub fn included_windows(&self) -> Vec<SCWindow> {
226        let count = unsafe { ffi::sc_content_filter_get_included_windows_count(self.0) };
227        if count <= 0 {
228            return Vec::new();
229        }
230        #[allow(clippy::cast_sign_loss)]
231        (0..count as usize)
232            .filter_map(|i| {
233                #[allow(clippy::cast_possible_wrap)]
234                let ptr =
235                    unsafe { ffi::sc_content_filter_get_included_window_at(self.0, i as isize) };
236                unsafe { SCWindow::from_retained_ptr(ptr) }
237            })
238            .collect()
239    }
240
241    /// Get included applications (macOS 15.2+)
242    ///
243    /// Returns the applications currently included in this filter.
244    #[cfg(feature = "macos_15_2")]
245    pub fn included_applications(&self) -> Vec<SCRunningApplication> {
246        let count = unsafe { ffi::sc_content_filter_get_included_applications_count(self.0) };
247        if count <= 0 {
248            return Vec::new();
249        }
250        #[allow(clippy::cast_sign_loss)]
251        (0..count as usize)
252            .filter_map(|i| {
253                #[allow(clippy::cast_possible_wrap)]
254                let ptr = unsafe {
255                    ffi::sc_content_filter_get_included_application_at(self.0, i as isize)
256                };
257                unsafe { SCRunningApplication::from_retained_ptr(ptr) }
258            })
259            .collect()
260    }
261}
262
263/// Content style for filters (macOS 14.0+)
264#[repr(i32)]
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
266#[cfg(feature = "macos_14_0")]
267pub enum SCShareableContentStyle {
268    /// No specific content type
269    #[default]
270    None = 0,
271    /// Window-based content
272    Window = 1,
273    /// Display-based content
274    Display = 2,
275    /// Application-based content
276    Application = 3,
277}
278
279#[cfg(feature = "macos_14_0")]
280impl From<i32> for SCShareableContentStyle {
281    fn from(value: i32) -> Self {
282        match value {
283            1 => Self::Window,
284            2 => Self::Display,
285            3 => Self::Application,
286            _ => Self::None,
287        }
288    }
289}
290
291#[cfg(feature = "macos_14_0")]
292impl std::fmt::Display for SCShareableContentStyle {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        match self {
295            Self::None => write!(f, "None"),
296            Self::Window => write!(f, "Window"),
297            Self::Display => write!(f, "Display"),
298            Self::Application => write!(f, "Application"),
299        }
300    }
301}
302
303/// Stream type for filters (macOS 14.0+)
304#[repr(i32)]
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
306#[cfg(feature = "macos_14_0")]
307pub enum SCStreamType {
308    /// Window-based stream
309    #[default]
310    Window = 0,
311    /// Display-based stream
312    Display = 1,
313}
314
315#[cfg(feature = "macos_14_0")]
316impl From<i32> for SCStreamType {
317    fn from(value: i32) -> Self {
318        match value {
319            1 => Self::Display,
320            _ => Self::Window,
321        }
322    }
323}
324
325#[cfg(feature = "macos_14_0")]
326impl std::fmt::Display for SCStreamType {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        match self {
329            Self::Window => write!(f, "Window"),
330            Self::Display => write!(f, "Display"),
331        }
332    }
333}
334
335// `Clone::clone` is not a `memcpy`: it crosses the Swift FFI boundary and calls
336// `sc_content_filter_retain` (an Objective-C `retain`). For hot-path code that
337// needs many references to the same filter, prefer `Arc<SCContentFilter>` over
338// per-call `.clone()`.
339crate::utils::retained::sc_retained!(
340    SCContentFilter,
341    retain = crate::ffi::sc_content_filter_retain,
342    release = crate::ffi::sc_content_filter_release,
343);
344
345impl fmt::Debug for SCContentFilter {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        f.debug_struct("SCContentFilter")
348            .field("ptr", &self.0)
349            .finish()
350    }
351}
352
353impl fmt::Display for SCContentFilter {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        write!(f, "SCContentFilter")
356    }
357}
358
359// Safety: SCContentFilter wraps an Objective-C object that is thread-safe
360// The underlying SCContentFilter object can be safely sent between threads
361unsafe impl Send for SCContentFilter {}
362unsafe impl Sync for SCContentFilter {}
363
364/// Builder for creating `SCContentFilter` instances
365///
366/// # Examples
367///
368/// ```no_run
369/// use screencapturekit::prelude::*;
370///
371/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
372/// let content = SCShareableContent::get()?;
373/// let display = &content.displays()[0];
374///
375/// // Capture entire display
376/// let filter = SCContentFilter::create()
377///     .with_display(display)
378///     .with_excluding_windows(&[])
379///     .build();
380///
381/// // Capture with specific windows excluded
382/// let window = &content.windows()[0];
383/// let filter = SCContentFilter::create()
384///     .with_display(display)
385///     .with_excluding_windows(&[window])
386///     .build();
387///
388/// // Capture specific window
389/// let filter = SCContentFilter::create()
390///     .with_window(window)
391///     .build();
392/// # Ok(())
393/// # }
394/// ```
395pub struct SCContentFilterBuilder {
396    filter_type: FilterType,
397    #[cfg(feature = "macos_14_2")]
398    content_rect: Option<CGRect>,
399}
400
401enum FilterType {
402    None,
403    Window(SCWindow),
404    DisplayExcluding {
405        display: SCDisplay,
406        windows: Vec<SCWindow>,
407    },
408    DisplayIncluding {
409        display: SCDisplay,
410        windows: Vec<SCWindow>,
411    },
412    DisplayIncludingApplications {
413        display: SCDisplay,
414        applications: Vec<SCRunningApplication>,
415        excepting_windows: Vec<SCWindow>,
416    },
417    DisplayExcludingApplications {
418        display: SCDisplay,
419        applications: Vec<SCRunningApplication>,
420        excepting_windows: Vec<SCWindow>,
421    },
422}
423
424impl SCContentFilterBuilder {
425    fn new() -> Self {
426        Self {
427            filter_type: FilterType::None,
428            #[cfg(feature = "macos_14_2")]
429            content_rect: None,
430        }
431    }
432
433    /// Set the display to capture
434    #[must_use]
435    pub fn with_display(mut self, display: &SCDisplay) -> Self {
436        self.filter_type = FilterType::DisplayExcluding {
437            display: display.clone(),
438            windows: Vec::new(),
439        };
440        self
441    }
442
443    /// Set the window to capture
444    #[must_use]
445    pub fn with_window(mut self, window: &SCWindow) -> Self {
446        self.filter_type = FilterType::Window(window.clone());
447        self
448    }
449
450    /// Exclude specific windows from the display capture
451    #[must_use]
452    pub fn with_excluding_windows(mut self, windows: &[&SCWindow]) -> Self {
453        if let FilterType::DisplayExcluding {
454            windows: ref mut excluded,
455            ..
456        } = self.filter_type
457        {
458            // `clone()` on SCWindow is a Swift retain (FFI). Pre-size the Vec
459            // so we don't reallocate while pushing — at 200 windows this is
460            // ~half the per-element cost.
461            let mut v = Vec::with_capacity(windows.len());
462            v.extend(windows.iter().map(|w| (*w).clone()));
463            *excluded = v;
464        }
465        self
466    }
467
468    /// Include only specific windows in the display capture
469    #[must_use]
470    pub fn with_including_windows(mut self, windows: &[&SCWindow]) -> Self {
471        if let FilterType::DisplayExcluding { display, .. } = self.filter_type {
472            let mut v = Vec::with_capacity(windows.len());
473            v.extend(windows.iter().map(|w| (*w).clone()));
474            self.filter_type = FilterType::DisplayIncluding {
475                display,
476                windows: v,
477            };
478        }
479        self
480    }
481
482    /// Include specific applications and optionally except certain windows
483    #[must_use]
484    pub fn with_including_applications(
485        mut self,
486        applications: &[&SCRunningApplication],
487        excepting_windows: &[&SCWindow],
488    ) -> Self {
489        if let FilterType::DisplayExcluding { display, .. }
490        | FilterType::DisplayIncluding { display, .. } = self.filter_type
491        {
492            let mut apps = Vec::with_capacity(applications.len());
493            apps.extend(applications.iter().map(|a| (*a).clone()));
494            let mut wins = Vec::with_capacity(excepting_windows.len());
495            wins.extend(excepting_windows.iter().map(|w| (*w).clone()));
496            self.filter_type = FilterType::DisplayIncludingApplications {
497                display,
498                applications: apps,
499                excepting_windows: wins,
500            };
501        }
502        self
503    }
504
505    /// Exclude specific applications and optionally except certain windows
506    ///
507    /// Captures everything on the display except the specified applications.
508    /// Windows in `excepting_windows` will still be captured even if their
509    /// owning application is excluded.
510    #[must_use]
511    pub fn with_excluding_applications(
512        mut self,
513        applications: &[&SCRunningApplication],
514        excepting_windows: &[&SCWindow],
515    ) -> Self {
516        if let FilterType::DisplayExcluding { display, .. }
517        | FilterType::DisplayIncluding { display, .. } = self.filter_type
518        {
519            let mut apps = Vec::with_capacity(applications.len());
520            apps.extend(applications.iter().map(|a| (*a).clone()));
521            let mut wins = Vec::with_capacity(excepting_windows.len());
522            wins.extend(excepting_windows.iter().map(|w| (*w).clone()));
523            self.filter_type = FilterType::DisplayExcludingApplications {
524                display,
525                applications: apps,
526                excepting_windows: wins,
527            };
528        }
529        self
530    }
531
532    /// Set the content rectangle (macOS 14.2+)
533    #[cfg(feature = "macos_14_2")]
534    #[must_use]
535    pub fn with_content_rect(mut self, rect: CGRect) -> Self {
536        self.content_rect = Some(rect);
537        self
538    }
539
540    // =========================================================================
541    // Deprecated methods - use with_* versions instead
542    // =========================================================================
543
544    /// Set the display to capture
545    #[must_use]
546    #[deprecated(since = "1.5.0", note = "Use with_display() instead")]
547    pub fn display(self, display: &SCDisplay) -> Self {
548        self.with_display(display)
549    }
550
551    /// Set the window to capture
552    #[must_use]
553    #[deprecated(since = "1.5.0", note = "Use with_window() instead")]
554    pub fn window(self, window: &SCWindow) -> Self {
555        self.with_window(window)
556    }
557
558    /// Exclude specific windows from the display capture
559    #[must_use]
560    #[deprecated(since = "1.5.0", note = "Use with_excluding_windows() instead")]
561    pub fn exclude_windows(self, windows: &[&SCWindow]) -> Self {
562        self.with_excluding_windows(windows)
563    }
564
565    /// Include only specific windows in the display capture
566    #[must_use]
567    #[deprecated(since = "1.5.0", note = "Use with_including_windows() instead")]
568    pub fn include_windows(self, windows: &[&SCWindow]) -> Self {
569        self.with_including_windows(windows)
570    }
571
572    /// Include specific applications and optionally except certain windows
573    #[must_use]
574    #[deprecated(since = "1.5.0", note = "Use with_including_applications() instead")]
575    pub fn include_applications(
576        self,
577        applications: &[&SCRunningApplication],
578        excepting_windows: &[&SCWindow],
579    ) -> Self {
580        self.with_including_applications(applications, excepting_windows)
581    }
582
583    /// Exclude specific applications and optionally except certain windows
584    #[must_use]
585    #[deprecated(since = "1.5.0", note = "Use with_excluding_applications() instead")]
586    pub fn exclude_applications(
587        self,
588        applications: &[&SCRunningApplication],
589        excepting_windows: &[&SCWindow],
590    ) -> Self {
591        self.with_excluding_applications(applications, excepting_windows)
592    }
593
594    /// Set the content rectangle (macOS 14.2+)
595    #[cfg(feature = "macos_14_2")]
596    #[must_use]
597    #[deprecated(since = "1.5.0", note = "Use with_content_rect() instead")]
598    pub fn content_rect(self, rect: CGRect) -> Self {
599        self.with_content_rect(rect)
600    }
601
602    /// Build the content filter.
603    ///
604    /// # Panics
605    ///
606    /// Panics if no filter type was set. Call `.display()` or `.window()` before `.build()`.
607    /// For a non-panicking alternative that reports this as a recoverable error, use
608    /// [`try_build`](Self::try_build).
609    #[must_use]
610    pub fn build(self) -> SCContentFilter {
611        self.try_build()
612            .expect("SCContentFilterBuilder: No filter type set. Call .display() or .window() before .build()")
613    }
614
615    /// Build the content filter, returning an error instead of panicking when no
616    /// filter type was set.
617    ///
618    /// # Errors
619    ///
620    /// Returns [`SCError::InvalidConfiguration`] if neither `.display()` nor `.window()`
621    /// was called before building.
622    #[allow(clippy::too_many_lines)]
623    pub fn try_build(self) -> SCResult<SCContentFilter> {
624        let filter = match self.filter_type {
625            FilterType::Window(window) => unsafe {
626                let ptr =
627                    ffi::sc_content_filter_create_with_desktop_independent_window(window.as_ptr());
628                SCContentFilter(ptr)
629            },
630            FilterType::DisplayExcluding { display, windows } => {
631                let window_refs: Vec<&SCWindow> = windows.iter().collect();
632                unsafe {
633                    let window_ptrs: Vec<*const c_void> =
634                        window_refs.iter().map(|w| w.as_ptr()).collect();
635
636                    let ptr = if window_ptrs.is_empty() {
637                        ffi::sc_content_filter_create_with_display_excluding_windows(
638                            display.as_ptr(),
639                            std::ptr::null(),
640                            0,
641                        )
642                    } else {
643                        #[allow(clippy::cast_possible_wrap)]
644                        ffi::sc_content_filter_create_with_display_excluding_windows(
645                            display.as_ptr(),
646                            window_ptrs.as_ptr(),
647                            window_ptrs.len() as isize,
648                        )
649                    };
650                    SCContentFilter(ptr)
651                }
652            }
653            FilterType::DisplayIncluding { display, windows } => {
654                let window_refs: Vec<&SCWindow> = windows.iter().collect();
655                unsafe {
656                    let window_ptrs: Vec<*const c_void> =
657                        window_refs.iter().map(|w| w.as_ptr()).collect();
658
659                    let ptr = if window_ptrs.is_empty() {
660                        ffi::sc_content_filter_create_with_display_including_windows(
661                            display.as_ptr(),
662                            std::ptr::null(),
663                            0,
664                        )
665                    } else {
666                        #[allow(clippy::cast_possible_wrap)]
667                        ffi::sc_content_filter_create_with_display_including_windows(
668                            display.as_ptr(),
669                            window_ptrs.as_ptr(),
670                            window_ptrs.len() as isize,
671                        )
672                    };
673                    SCContentFilter(ptr)
674                }
675            }
676            FilterType::DisplayIncludingApplications {
677                display,
678                applications,
679                excepting_windows,
680            } => {
681                let app_refs: Vec<&SCRunningApplication> = applications.iter().collect();
682                let window_refs: Vec<&SCWindow> = excepting_windows.iter().collect();
683                unsafe {
684                    let app_ptrs: Vec<*const c_void> =
685                        app_refs.iter().map(|a| a.as_ptr()).collect();
686
687                    let window_ptrs: Vec<*const c_void> =
688                        window_refs.iter().map(|w| w.as_ptr()).collect();
689
690                    #[allow(clippy::cast_possible_wrap)]
691                    let ptr = ffi::sc_content_filter_create_with_display_including_applications_excepting_windows(
692                        display.as_ptr(),
693                        if app_ptrs.is_empty() { std::ptr::null() } else { app_ptrs.as_ptr() },
694                        app_ptrs.len() as isize,
695                        if window_ptrs.is_empty() { std::ptr::null() } else { window_ptrs.as_ptr() },
696                        window_ptrs.len() as isize,
697                    );
698                    SCContentFilter(ptr)
699                }
700            }
701            FilterType::DisplayExcludingApplications {
702                display,
703                applications,
704                excepting_windows,
705            } => {
706                let app_refs: Vec<&SCRunningApplication> = applications.iter().collect();
707                let window_refs: Vec<&SCWindow> = excepting_windows.iter().collect();
708                unsafe {
709                    let app_ptrs: Vec<*const c_void> =
710                        app_refs.iter().map(|a| a.as_ptr()).collect();
711
712                    let window_ptrs: Vec<*const c_void> =
713                        window_refs.iter().map(|w| w.as_ptr()).collect();
714
715                    #[allow(clippy::cast_possible_wrap)]
716                    let ptr = ffi::sc_content_filter_create_with_display_excluding_applications_excepting_windows(
717                        display.as_ptr(),
718                        if app_ptrs.is_empty() { std::ptr::null() } else { app_ptrs.as_ptr() },
719                        app_ptrs.len() as isize,
720                        if window_ptrs.is_empty() { std::ptr::null() } else { window_ptrs.as_ptr() },
721                        window_ptrs.len() as isize,
722                    );
723                    SCContentFilter(ptr)
724                }
725            }
726            FilterType::None => {
727                return Err(SCError::invalid_config(
728                    "SCContentFilterBuilder: No filter type set. \
729                     Call .display() or .window() before building.",
730                ));
731            }
732        };
733
734        // Apply content rect if set (macOS 14.2+)
735        #[cfg(feature = "macos_14_2")]
736        let filter = if let Some(rect) = self.content_rect {
737            filter.set_content_rect(rect)
738        } else {
739            filter
740        };
741
742        Ok(filter)
743    }
744}
745
746impl std::fmt::Debug for SCContentFilterBuilder {
747    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748        let filter_type_name = match &self.filter_type {
749            FilterType::None => "None",
750            FilterType::Window(_) => "Window",
751            FilterType::DisplayExcluding { .. } => "DisplayExcluding",
752            FilterType::DisplayIncluding { .. } => "DisplayIncluding",
753            FilterType::DisplayIncludingApplications { .. } => "DisplayIncludingApplications",
754            FilterType::DisplayExcludingApplications { .. } => "DisplayExcludingApplications",
755        };
756
757        let mut debug = f.debug_struct("SCContentFilterBuilder");
758        debug.field("filter_type", &filter_type_name);
759
760        #[cfg(feature = "macos_14_2")]
761        debug.field("content_rect", &self.content_rect);
762
763        debug.finish()
764    }
765}