Skip to main content

screencapturekit/
screenshot_manager.rs

1//! `SCScreenshotManager` - Single-shot screenshot capture
2//!
3//! Available on macOS 14.0+.
4//! Provides high-quality screenshot capture without the overhead of setting up a stream.
5//!
6//! ## When to Use
7//!
8//! Use `SCScreenshotManager` when you need:
9//! - A single screenshot rather than continuous capture
10//! - Quick capture without stream setup/teardown overhead
11//! - Direct saving to image files
12//!
13//! For continuous capture, use [`SCStream`](crate::stream::SCStream) instead.
14//!
15//! ## Example
16//!
17//! ```no_run
18//! use screencapturekit::screenshot_manager::{CGImageExt, ImageFormat, SCScreenshotManager};
19//! use screencapturekit::stream::{content_filter::SCContentFilter, configuration::SCStreamConfiguration};
20//! use screencapturekit::shareable_content::SCShareableContent;
21//!
22//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
23//! let content = SCShareableContent::get()?;
24//! let display = &content.displays()[0];
25//! let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
26//! let config = SCStreamConfiguration::new()
27//!     .with_width(1920)
28//!     .with_height(1080);
29//!
30//! // Capture as CGImage
31//! let image = SCScreenshotManager::capture_image(&filter, &config)?;
32//! println!("Screenshot: {}x{}", image.width(), image.height());
33//!
34//! // Save to file
35//! image.save_png("screenshot.png")?;
36//!
37//! // Or save as JPEG with quality
38//! image.save("screenshot.jpg", ImageFormat::Jpeg(0.85))?;
39//! # Ok(())
40//! # }
41//! ```
42
43use crate::error::SCError;
44use crate::stream::configuration::SCStreamConfiguration;
45use crate::stream::content_filter::SCContentFilter;
46use crate::utils::completion::{error_from_cstr, SyncCompletion};
47use std::ffi::c_void;
48
49#[cfg(feature = "macos_15_2")]
50use crate::cg::CGRect;
51#[cfg(feature = "macos_26_0")]
52use crate::stream::configuration::InteriorNulError;
53
54#[doc(no_inline)]
55pub use apple_cf::cg::CGImage;
56
57/// Image output format for saving screenshots
58///
59/// # Examples
60///
61/// ```no_run
62/// use screencapturekit::screenshot_manager::ImageFormat;
63///
64/// // PNG for lossless quality
65/// let format = ImageFormat::Png;
66///
67/// // JPEG with 80% quality
68/// let format = ImageFormat::Jpeg(0.8);
69///
70/// // HEIC with 90% quality (smaller file size than JPEG)
71/// let format = ImageFormat::Heic(0.9);
72/// ```
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub enum ImageFormat {
75    /// PNG format (lossless)
76    Png,
77    /// JPEG format with quality (0.0-1.0)
78    Jpeg(f32),
79    /// TIFF format (lossless)
80    Tiff,
81    /// GIF format
82    Gif,
83    /// BMP format
84    Bmp,
85    /// HEIC format with quality (0.0-1.0) - efficient compression
86    Heic(f32),
87}
88
89impl ImageFormat {
90    fn to_format_id(self) -> i32 {
91        match self {
92            Self::Png => 0,
93            Self::Jpeg(_) => 1,
94            Self::Tiff => 2,
95            Self::Gif => 3,
96            Self::Bmp => 4,
97            Self::Heic(_) => 5,
98        }
99    }
100
101    fn quality(self) -> f32 {
102        match self {
103            Self::Jpeg(q) | Self::Heic(q) => q.clamp(0.0, 1.0),
104            _ => 1.0,
105        }
106    }
107
108    /// Get the typical file extension for this format
109    #[must_use]
110    pub const fn extension(&self) -> &'static str {
111        match self {
112            Self::Png => "png",
113            Self::Jpeg(_) => "jpg",
114            Self::Tiff => "tiff",
115            Self::Gif => "gif",
116            Self::Bmp => "bmp",
117            Self::Heic(_) => "heic",
118        }
119    }
120}
121
122/// # Safety
123/// `ptr` must be a non-null retained `CGImageRef` whose +1 ownership is
124/// transferred to the returned wrapper.
125pub(crate) unsafe fn cgimage_from_retained_ptr(ptr: *const c_void) -> CGImage {
126    unsafe { CGImage::from_raw(ptr.cast_mut()) }
127}
128
129extern "C" fn image_callback(
130    image_ptr: *const c_void,
131    error_ptr: *const i8,
132    user_data: *mut c_void,
133) {
134    crate::utils::panic_safe::catch_user_panic("image_callback", move || {
135        if !error_ptr.is_null() {
136            // SAFETY: `error` is non-null (checked above) and points to a valid null-terminated C string provided by the Swift completion handler.
137            let error = unsafe { error_from_cstr(error_ptr) };
138            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
139            unsafe { SyncCompletion::<CGImage>::complete_err(user_data, error) };
140        } else if !image_ptr.is_null() {
141            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
142            unsafe { SyncCompletion::complete_ok(user_data, cgimage_from_retained_ptr(image_ptr)) };
143        } else {
144            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
145            unsafe {
146                SyncCompletion::<CGImage>::complete_err(user_data, "Unknown error".to_string());
147            };
148        }
149    });
150}
151
152extern "C" fn buffer_callback(
153    buffer_ptr: *const c_void,
154    error_ptr: *const i8,
155    user_data: *mut c_void,
156) {
157    crate::utils::panic_safe::catch_user_panic("buffer_callback", move || {
158        if !error_ptr.is_null() {
159            // SAFETY: `error` is non-null (checked above) and points to a valid null-terminated C string provided by the Swift completion handler.
160            let error = unsafe { error_from_cstr(error_ptr) };
161            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
162            unsafe { SyncCompletion::<crate::cm::CMSampleBuffer>::complete_err(user_data, error) };
163        } else if !buffer_ptr.is_null() {
164            // SAFETY: `buffer_ptr` is non-null (checked above), is a valid `CMSampleBuffer` pointer, and `cast_mut()` is sound because the underlying object is uniquely owned at this point.
165            let buffer = unsafe { crate::cm::CMSampleBuffer::from_ptr(buffer_ptr.cast_mut()) };
166            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
167            unsafe { SyncCompletion::complete_ok(user_data, buffer) };
168        } else {
169            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
170            unsafe {
171                SyncCompletion::<crate::cm::CMSampleBuffer>::complete_err(
172                    user_data,
173                    "Unknown error".to_string(),
174                );
175            };
176        }
177    });
178}
179
180#[cfg(feature = "macos_26_0")]
181extern "C" fn screenshot_output_callback(
182    output_ptr: *const c_void,
183    error_ptr: *const i8,
184    user_data: *mut c_void,
185) {
186    crate::utils::panic_safe::catch_user_panic("screenshot_output_callback", move || {
187        if !error_ptr.is_null() {
188            // SAFETY: `error` is non-null (checked above) and points to a valid null-terminated C string provided by the Swift completion handler.
189            let error = unsafe { error_from_cstr(error_ptr) };
190            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
191            unsafe { SyncCompletion::<SCScreenshotOutput>::complete_err(user_data, error) };
192        } else if !output_ptr.is_null() {
193            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
194            unsafe {
195                SyncCompletion::complete_ok(user_data, SCScreenshotOutput::from_ptr(output_ptr));
196            };
197        } else {
198            // SAFETY: `user_data` is the one-shot completion context from `SyncCompletion::create()`; Swift invokes this callback exactly once, so the pointer is still valid.
199            unsafe {
200                SyncCompletion::<SCScreenshotOutput>::complete_err(
201                    user_data,
202                    "Unknown error".to_string(),
203                );
204            };
205        }
206    });
207}
208
209/// Screenshot-specific helpers implemented for the canonical [`CGImage`] type.
210///
211/// Import this trait to access pixel extraction helpers and multi-format file
212/// export on images returned by [`SCScreenshotManager`].
213pub trait CGImageExt {
214    /// Get raw RGBA pixel data.
215    ///
216    /// # Errors
217    /// Returns an error if the pixel data cannot be extracted.
218    fn rgba_data(&self) -> Result<Vec<u8>, SCError>;
219
220    /// Get raw BGRA pixel data.
221    ///
222    /// # Errors
223    /// Returns an error if the pixel data cannot be extracted.
224    fn bgra_data(&self) -> Result<Vec<u8>, SCError>;
225
226    /// Render the image's RGBA bytes into a caller-supplied buffer.
227    ///
228    /// # Errors
229    /// Returns an error if `dest` is too small or the render fails.
230    ///
231    /// # Examples
232    ///
233    /// ```no_run
234    /// # use screencapturekit::screenshot_manager::{CGImageExt, SCScreenshotManager};
235    /// # use screencapturekit::stream::{content_filter::SCContentFilter, configuration::SCStreamConfiguration};
236    /// # use screencapturekit::shareable_content::SCShareableContent;
237    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
238    /// # let content = SCShareableContent::get()?;
239    /// # let display = &content.displays()[0];
240    /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
241    /// # let config = SCStreamConfiguration::new().with_width(1920).with_height(1080);
242    /// let mut buffer: Vec<u8> = vec![0; 1920 * 1080 * 4];
243    /// let img = SCScreenshotManager::capture_image(&filter, &config)?;
244    /// img.rgba_data_into(&mut buffer)?;
245    /// # Ok(())
246    /// # }
247    /// ```
248    fn rgba_data_into(&self, dest: &mut [u8]) -> Result<usize, SCError>;
249
250    /// Render the image's BGRA bytes into a caller-supplied buffer.
251    ///
252    /// # Errors
253    /// Returns an error if `dest` is too small or the render fails.
254    fn bgra_data_into(&self, dest: &mut [u8]) -> Result<usize, SCError>;
255
256    /// Render the image's RGBA bytes into a caller-supplied buffer using an
257    /// explicit row stride (`dest_bytes_per_row`).
258    ///
259    /// Unlike [`rgba_data_into`](CGImageExt::rgba_data_into), which assumes
260    /// tightly-packed rows (`width * 4`), this accepts a caller-specified row
261    /// stride so consumers with padded/row-aligned buffers (GPU upload, wgpu)
262    /// aren't forced into tight packing.
263    ///
264    /// Returns the number of bytes spanned (`height * dest_bytes_per_row`).
265    ///
266    /// # Errors
267    /// Returns an error if `dest_bytes_per_row` is smaller than `width * 4`,
268    /// if `dest` cannot hold `height * dest_bytes_per_row` bytes, or if the
269    /// render fails.
270    fn rgba_data_into_strided(
271        &self,
272        dest: &mut [u8],
273        dest_bytes_per_row: usize,
274    ) -> Result<usize, SCError>;
275
276    /// Render the image's BGRA bytes into a caller-supplied buffer using an
277    /// explicit row stride (`dest_bytes_per_row`).
278    ///
279    /// See [`rgba_data_into_strided`](CGImageExt::rgba_data_into_strided) for
280    /// the row-stride semantics.
281    ///
282    /// # Errors
283    /// Returns an error if `dest_bytes_per_row` is smaller than `width * 4`,
284    /// if `dest` cannot hold `height * dest_bytes_per_row` bytes, or if the
285    /// render fails.
286    fn bgra_data_into_strided(
287        &self,
288        dest: &mut [u8],
289        dest_bytes_per_row: usize,
290    ) -> Result<usize, SCError>;
291
292    /// Save the image to a file in the specified format.
293    ///
294    /// # Errors
295    /// Returns an error if the path contains interior null bytes or the export fails.
296    ///
297    /// # Examples
298    ///
299    /// ```no_run
300    /// # use screencapturekit::screenshot_manager::{CGImageExt, ImageFormat, SCScreenshotManager};
301    /// # use screencapturekit::stream::{content_filter::SCContentFilter, configuration::SCStreamConfiguration};
302    /// # use screencapturekit::shareable_content::SCShareableContent;
303    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
304    /// # let content = SCShareableContent::get()?;
305    /// # let display = &content.displays()[0];
306    /// # let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
307    /// # let config = SCStreamConfiguration::new().with_width(1920).with_height(1080);
308    /// let image = SCScreenshotManager::capture_image(&filter, &config)?;
309    /// image.save("screenshot.png", ImageFormat::Png)?;
310    /// image.save("screenshot.jpg", ImageFormat::Jpeg(0.85))?;
311    /// image.save("screenshot.heic", ImageFormat::Heic(0.9))?;
312    /// # Ok(())
313    /// # }
314    /// ```
315    fn save(&self, path: &str, format: ImageFormat) -> Result<(), SCError>;
316}
317
318/// Internal selector for the channel ordering passed to the Swift renderer.
319#[derive(Debug, Clone, Copy)]
320enum PixelLayout {
321    Rgba,
322    Bgra,
323}
324
325impl PixelLayout {
326    const fn name(self) -> &'static str {
327        match self {
328            Self::Rgba => "RGBA",
329            Self::Bgra => "BGRA",
330        }
331    }
332
333    /// Dispatch into the matching Swift bridge entry point.
334    ///
335    /// # Safety
336    /// The destination must point to at least `capacity` bytes and `ptr` must
337    /// be a live retained `CGImage`.
338    unsafe fn render(self, ptr: *const c_void, dest: *mut u8, capacity: usize) -> usize {
339        unsafe {
340            match self {
341                Self::Rgba => crate::ffi::cgimage_render_rgba_into(ptr, dest, capacity),
342                Self::Bgra => crate::ffi::cgimage_render_bgra_into(ptr, dest, capacity),
343            }
344        }
345    }
346
347    /// Dispatch into the matching strided Swift bridge entry point.
348    ///
349    /// # Safety
350    /// The destination must point to at least `capacity` bytes, span
351    /// `bytes_per_row` per image row, and `ptr` must be a live retained
352    /// `CGImage`.
353    unsafe fn render_strided(
354        self,
355        ptr: *const c_void,
356        dest: *mut u8,
357        capacity: usize,
358        bytes_per_row: usize,
359    ) -> usize {
360        unsafe {
361            match self {
362                Self::Rgba => {
363                    crate::ffi::cgimage_render_rgba_into_strided(ptr, dest, capacity, bytes_per_row)
364                }
365                Self::Bgra => {
366                    crate::ffi::cgimage_render_bgra_into_strided(ptr, dest, capacity, bytes_per_row)
367                }
368            }
369        }
370    }
371}
372
373impl CGImageExt for CGImage {
374    fn rgba_data(&self) -> Result<Vec<u8>, SCError> {
375        render_pixel_data(self, PixelLayout::Rgba)
376    }
377
378    fn bgra_data(&self) -> Result<Vec<u8>, SCError> {
379        render_pixel_data(self, PixelLayout::Bgra)
380    }
381
382    fn rgba_data_into(&self, dest: &mut [u8]) -> Result<usize, SCError> {
383        render_pixel_data_into(self, dest, PixelLayout::Rgba)
384    }
385
386    fn bgra_data_into(&self, dest: &mut [u8]) -> Result<usize, SCError> {
387        render_pixel_data_into(self, dest, PixelLayout::Bgra)
388    }
389
390    fn rgba_data_into_strided(
391        &self,
392        dest: &mut [u8],
393        dest_bytes_per_row: usize,
394    ) -> Result<usize, SCError> {
395        render_pixel_data_into_strided(self, dest, dest_bytes_per_row, PixelLayout::Rgba)
396    }
397
398    fn bgra_data_into_strided(
399        &self,
400        dest: &mut [u8],
401        dest_bytes_per_row: usize,
402    ) -> Result<usize, SCError> {
403        render_pixel_data_into_strided(self, dest, dest_bytes_per_row, PixelLayout::Bgra)
404    }
405
406    fn save(&self, path: &str, format: ImageFormat) -> Result<(), SCError> {
407        let c_path = std::ffi::CString::new(path)
408            .map_err(|_| SCError::internal_error("Path contains null bytes"))?;
409
410        let success = unsafe {
411            crate::ffi::cgimage_save_to_file(
412                self.as_ptr(),
413                c_path.as_ptr(),
414                format.to_format_id(),
415                format.quality(),
416            )
417        };
418
419        if success {
420            Ok(())
421        } else {
422            Err(SCError::internal_error(format!(
423                "Failed to save image as {}",
424                format.extension().to_uppercase()
425            )))
426        }
427    }
428}
429
430fn render_pixel_data(image: &CGImage, layout: PixelLayout) -> Result<Vec<u8>, SCError> {
431    let total_bytes = required_byte_size(image)?;
432    if total_bytes == 0 {
433        return Ok(Vec::new());
434    }
435
436    let mut data: Vec<u8> = Vec::with_capacity(total_bytes);
437    let written = unsafe { layout.render(image.as_ptr(), data.as_mut_ptr(), total_bytes) };
438
439    if written != total_bytes {
440        return Err(SCError::internal_error(format!(
441            "Failed to render CGImage into {} buffer",
442            layout.name()
443        )));
444    }
445
446    unsafe { data.set_len(total_bytes) };
447    Ok(data)
448}
449
450fn render_pixel_data_into(
451    image: &CGImage,
452    dest: &mut [u8],
453    layout: PixelLayout,
454) -> Result<usize, SCError> {
455    let total_bytes = required_byte_size(image)?;
456    if dest.len() < total_bytes {
457        return Err(SCError::internal_error(format!(
458            "Destination buffer too small: need {total_bytes} bytes, got {}",
459            dest.len()
460        )));
461    }
462    if total_bytes == 0 {
463        return Ok(0);
464    }
465
466    let written = unsafe { layout.render(image.as_ptr(), dest.as_mut_ptr(), total_bytes) };
467    if written != total_bytes {
468        return Err(SCError::internal_error(format!(
469            "Failed to render CGImage into {} buffer",
470            layout.name()
471        )));
472    }
473    Ok(written)
474}
475
476fn render_pixel_data_into_strided(
477    image: &CGImage,
478    dest: &mut [u8],
479    dest_bytes_per_row: usize,
480    layout: PixelLayout,
481) -> Result<usize, SCError> {
482    let width = image.width();
483    let height = image.height();
484
485    let min_bytes_per_row = width
486        .checked_mul(4)
487        .ok_or_else(|| SCError::internal_error("CGImage row size overflows usize"))?;
488    if dest_bytes_per_row < min_bytes_per_row {
489        return Err(SCError::internal_error(format!(
490            "Destination row stride too small: need at least {min_bytes_per_row} bytes, got {dest_bytes_per_row}"
491        )));
492    }
493
494    let required = height
495        .checked_mul(dest_bytes_per_row)
496        .ok_or_else(|| SCError::internal_error("CGImage strided size overflows usize"))?;
497    if dest.len() < required {
498        return Err(SCError::internal_error(format!(
499            "Destination buffer too small: need {required} bytes, got {}",
500            dest.len()
501        )));
502    }
503    if required == 0 {
504        return Ok(0);
505    }
506
507    let written = unsafe {
508        layout.render_strided(
509            image.as_ptr(),
510            dest.as_mut_ptr(),
511            dest.len(),
512            dest_bytes_per_row,
513        )
514    };
515    if written != required {
516        return Err(SCError::internal_error(format!(
517            "Failed to render CGImage into {} buffer",
518            layout.name()
519        )));
520    }
521    Ok(written)
522}
523
524fn required_byte_size(image: &CGImage) -> Result<usize, SCError> {
525    image
526        .width()
527        .checked_mul(image.height())
528        .and_then(|n| n.checked_mul(4))
529        .ok_or_else(|| SCError::internal_error("CGImage dimensions overflow usize"))
530}
531
532/// Manager for capturing single screenshots
533///
534/// Available on macOS 14.0+. Provides a simpler API than `SCStream` for one-time captures.
535///
536/// # Examples
537///
538/// ```no_run
539/// use screencapturekit::screenshot_manager::SCScreenshotManager;
540/// use screencapturekit::stream::{content_filter::SCContentFilter, configuration::SCStreamConfiguration};
541/// use screencapturekit::shareable_content::SCShareableContent;
542///
543/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
544/// let content = SCShareableContent::get()?;
545/// let display = &content.displays()[0];
546/// let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
547/// let config = SCStreamConfiguration::new()
548///     .with_width(1920)
549///     .with_height(1080);
550///
551/// let image = SCScreenshotManager::capture_image(&filter, &config)?;
552/// println!("Captured screenshot: {}x{}", image.width(), image.height());
553/// # Ok(())
554/// # }
555/// ```
556#[derive(Debug)]
557pub struct SCScreenshotManager;
558
559impl SCScreenshotManager {
560    /// Capture a single screenshot as a `CGImage`
561    ///
562    /// # Errors
563    /// Returns an error if:
564    /// - The system is not macOS 14.0+
565    /// - Screen recording permission is not granted
566    /// - The capture fails for any reason
567    ///
568    /// # Panics
569    /// Panics if the internal mutex is poisoned.
570    pub fn capture_image(
571        content_filter: &SCContentFilter,
572        configuration: &SCStreamConfiguration,
573    ) -> Result<CGImage, SCError> {
574        let configuration = configuration.clone();
575        let (completion, context) = SyncCompletion::<CGImage>::new();
576
577        unsafe {
578            crate::ffi::sc_screenshot_manager_capture_image(
579                content_filter.as_ptr(),
580                configuration.as_ptr(),
581                image_callback,
582                context,
583            );
584        }
585
586        completion.wait().map_err(SCError::ScreenshotError)
587    }
588
589    /// Capture a single screenshot as a `CMSampleBuffer`
590    ///
591    /// Returns the sample buffer for advanced processing.
592    ///
593    /// # Errors
594    /// Returns an error if:
595    /// - The system is not macOS 14.0+
596    /// - Screen recording permission is not granted
597    /// - The capture fails for any reason
598    ///
599    /// # Panics
600    /// Panics if the internal mutex is poisoned.
601    pub fn capture_sample_buffer(
602        content_filter: &SCContentFilter,
603        configuration: &SCStreamConfiguration,
604    ) -> Result<crate::cm::CMSampleBuffer, SCError> {
605        let configuration = configuration.clone();
606        let (completion, context) = SyncCompletion::<crate::cm::CMSampleBuffer>::new();
607
608        unsafe {
609            crate::ffi::sc_screenshot_manager_capture_sample_buffer(
610                content_filter.as_ptr(),
611                configuration.as_ptr(),
612                buffer_callback,
613                context,
614            );
615        }
616
617        completion.wait().map_err(SCError::ScreenshotError)
618    }
619
620    /// Capture a screenshot of a specific screen region (macOS 15.2+)
621    ///
622    /// This method captures the content within the specified rectangle,
623    /// which can span multiple displays.
624    ///
625    /// # Arguments
626    /// * `rect` - The rectangle to capture, in screen coordinates (points)
627    ///
628    /// # Errors
629    /// Returns an error if:
630    /// - The system is not macOS 15.2+
631    /// - Screen recording permission is not granted
632    /// - The capture fails for any reason
633    ///
634    /// # Examples
635    /// ```no_run
636    /// use screencapturekit::screenshot_manager::SCScreenshotManager;
637    /// use screencapturekit::cg::CGRect;
638    ///
639    /// fn example() -> Result<(), screencapturekit::utils::error::SCError> {
640    ///     let rect = CGRect::new(0.0, 0.0, 1920.0, 1080.0);
641    ///     let image = SCScreenshotManager::capture_image_in_rect(rect)?;
642    ///     Ok(())
643    /// }
644    /// ```
645    #[cfg(feature = "macos_15_2")]
646    pub fn capture_image_in_rect(rect: CGRect) -> Result<CGImage, SCError> {
647        let (completion, context) = SyncCompletion::<CGImage>::new();
648
649        unsafe {
650            crate::ffi::sc_screenshot_manager_capture_image_in_rect(
651                rect.origin.x,
652                rect.origin.y,
653                rect.size.width,
654                rect.size.height,
655                image_callback,
656                context,
657            );
658        }
659
660        completion.wait().map_err(SCError::ScreenshotError)
661    }
662
663    /// Capture a screenshot with advanced configuration (macOS 26.0+)
664    ///
665    /// This method uses the new `SCScreenshotConfiguration` for more control
666    /// over the screenshot output, including HDR support and file saving.
667    ///
668    /// # Arguments
669    /// * `content_filter` - The content filter specifying what to capture
670    /// * `configuration` - The screenshot configuration
671    ///
672    /// # Errors
673    /// Returns an error if the capture fails
674    ///
675    /// # Examples
676    /// ```no_run
677    /// use screencapturekit::screenshot_manager::{SCScreenshotManager, SCScreenshotConfiguration, SCScreenshotDynamicRange};
678    /// use screencapturekit::stream::content_filter::SCContentFilter;
679    /// use screencapturekit::shareable_content::SCShareableContent;
680    ///
681    /// fn example() -> Option<()> {
682    ///     let content = SCShareableContent::get().ok()?;
683    ///     let displays = content.displays();
684    ///     let display = displays.first()?;
685    ///     let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build().ok()?;
686    ///     let config = SCScreenshotConfiguration::new().ok()?
687    ///         .with_width(1920)
688    ///         .with_height(1080)
689    ///         .with_dynamic_range(SCScreenshotDynamicRange::BothSDRAndHDR);
690    ///
691    ///     let output = SCScreenshotManager::capture_screenshot(&filter, &config).ok()?;
692    ///     if let Some(sdr) = output.sdr_image() {
693    ///         println!("SDR image: {}x{}", sdr.width(), sdr.height());
694    ///     }
695    ///     Some(())
696    /// }
697    /// ```
698    #[cfg(feature = "macos_26_0")]
699    pub fn capture_screenshot(
700        content_filter: &SCContentFilter,
701        configuration: &SCScreenshotConfiguration,
702    ) -> Result<SCScreenshotOutput, SCError> {
703        let (completion, context) = SyncCompletion::<SCScreenshotOutput>::new();
704
705        unsafe {
706            crate::ffi::sc_screenshot_manager_capture_screenshot(
707                content_filter.as_ptr(),
708                configuration.as_ptr(),
709                screenshot_output_callback,
710                context,
711            );
712        }
713
714        completion.wait().map_err(SCError::ScreenshotError)
715    }
716
717    /// Capture a screenshot of a specific region with advanced configuration (macOS 26.0+)
718    ///
719    /// # Arguments
720    /// * `rect` - The rectangle to capture, in screen coordinates (points)
721    /// * `configuration` - The screenshot configuration
722    ///
723    /// # Errors
724    /// Returns an error if the capture fails
725    #[cfg(feature = "macos_26_0")]
726    pub fn capture_screenshot_in_rect(
727        rect: crate::cg::CGRect,
728        configuration: &SCScreenshotConfiguration,
729    ) -> Result<SCScreenshotOutput, SCError> {
730        let (completion, context) = SyncCompletion::<SCScreenshotOutput>::new();
731
732        unsafe {
733            crate::ffi::sc_screenshot_manager_capture_screenshot_in_rect(
734                rect.origin.x,
735                rect.origin.y,
736                rect.size.width,
737                rect.size.height,
738                configuration.as_ptr(),
739                screenshot_output_callback,
740                context,
741            );
742        }
743
744        completion.wait().map_err(SCError::ScreenshotError)
745    }
746}
747
748// ============================================================================
749// SCScreenshotConfiguration (macOS 26.0+)
750// ============================================================================
751
752/// `UTType` identifiers are reverse-DNS strings; 256 bytes is comfortably
753/// above the longest identifier Apple ships and the bridge reports overflow as
754/// failure rather than truncating.
755#[cfg(feature = "macos_26_0")]
756const UTTYPE_IDENTIFIER_BUFFER: usize = crate::utils::ffi_string::SMALL_BUFFER_SIZE;
757
758/// Decode a bridge-owned C string into a `PathBuf` without going through
759/// `String`: file system paths are arbitrary non-NUL bytes on Darwin, and
760/// lossy UTF-8 conversion would silently rename them.
761#[cfg(feature = "macos_26_0")]
762fn owned_path<F: FnOnce() -> *mut i8>(ffi_call: F) -> Option<std::path::PathBuf> {
763    use std::os::unix::ffi::OsStrExt;
764
765    let ptr = ffi_call();
766    if ptr.is_null() {
767        return None;
768    }
769    // SAFETY: the bridge returns a `strdup`-allocated NUL-terminated buffer
770    // that ownership of transfers to us here.
771    let bytes = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_bytes().to_vec();
772    unsafe { crate::ffi::sc_free_string(ptr) };
773    if bytes.is_empty() {
774        return None;
775    }
776    Some(std::path::PathBuf::from(std::ffi::OsStr::from_bytes(
777        &bytes,
778    )))
779}
780
781/// Drive one of the bridge's four-out-param rect getters.
782#[cfg(feature = "macos_26_0")]
783fn read_rect<F>(ffi_call: F) -> crate::cg::CGRect
784where
785    F: FnOnce(*mut f64, *mut f64, *mut f64, *mut f64),
786{
787    let mut x = 0.0;
788    let mut y = 0.0;
789    let mut width = 0.0;
790    let mut height = 0.0;
791    ffi_call(&raw mut x, &raw mut y, &raw mut width, &raw mut height);
792    crate::cg::CGRect::new(x, y, width, height)
793}
794
795/// Display intent for screenshot rendering (macOS 26.0+)
796#[cfg(feature = "macos_26_0")]
797#[repr(i32)]
798#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
799pub enum SCScreenshotDisplayIntent {
800    /// Render on the canonical display
801    #[default]
802    Canonical = 0,
803    /// Render on the local display
804    Local = 1,
805}
806
807#[cfg(feature = "macos_26_0")]
808impl SCScreenshotDisplayIntent {
809    pub const fn from_raw(raw: i32) -> Option<Self> {
810        match raw {
811            0 => Some(Self::Canonical),
812            1 => Some(Self::Local),
813            _ => None,
814        }
815    }
816}
817
818/// Dynamic range for screenshot output (macOS 26.0+)
819#[cfg(feature = "macos_26_0")]
820#[repr(i32)]
821#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
822pub enum SCScreenshotDynamicRange {
823    /// SDR output only
824    #[default]
825    SDR = 0,
826    /// HDR output only
827    HDR = 1,
828    /// Both SDR and HDR output
829    BothSDRAndHDR = 2,
830}
831
832#[cfg(feature = "macos_26_0")]
833impl SCScreenshotDynamicRange {
834    pub const fn from_raw(raw: i32) -> Option<Self> {
835        match raw {
836            0 => Some(Self::SDR),
837            1 => Some(Self::HDR),
838            2 => Some(Self::BothSDRAndHDR),
839            _ => None,
840        }
841    }
842}
843
844/// Why a screenshot file path could not be represented by Foundation.
845#[cfg(feature = "macos_26_0")]
846#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
847pub enum InvalidScreenshotPath {
848    /// Foundation file URLs require a valid UTF-8 path.
849    NotUtf8,
850    /// C strings cannot contain an interior NUL byte.
851    InteriorNul,
852}
853
854#[cfg(feature = "macos_26_0")]
855impl std::fmt::Display for InvalidScreenshotPath {
856    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
857        match self {
858            Self::NotUtf8 => f.write_str("screenshot path is not valid UTF-8"),
859            Self::InteriorNul => f.write_str("screenshot path contains an interior NUL byte"),
860        }
861    }
862}
863
864#[cfg(feature = "macos_26_0")]
865impl std::error::Error for InvalidScreenshotPath {}
866
867/// Configuration for advanced screenshot capture (macOS 26.0+)
868///
869/// Provides fine-grained control over screenshot output including:
870/// - Output dimensions
871/// - Source and destination rectangles
872/// - Shadow and clipping behavior
873/// - HDR/SDR dynamic range
874/// - File output
875///
876/// # Examples
877///
878/// ```no_run
879/// use screencapturekit::screenshot_manager::{SCScreenshotConfiguration, SCScreenshotDynamicRange};
880///
881/// let config = SCScreenshotConfiguration::new().expect("create screenshot configuration")
882///     .with_width(1920)
883///     .with_height(1080)
884///     .with_shows_cursor(true)
885///     .with_dynamic_range(SCScreenshotDynamicRange::BothSDRAndHDR);
886/// ```
887#[cfg(feature = "macos_26_0")]
888pub struct SCScreenshotConfiguration {
889    ptr: *const c_void,
890}
891
892#[cfg(feature = "macos_26_0")]
893impl SCScreenshotConfiguration {
894    /// Create a new screenshot configuration
895    ///
896    /// # Errors
897    /// Returns [`SCError::FeatureNotAvailable`] if the configuration cannot be
898    /// created (requires macOS 26.0+)
899    pub fn new() -> Result<Self, SCError> {
900        let ptr = unsafe { crate::ffi::sc_screenshot_configuration_create() };
901        if ptr.is_null() {
902            return Err(SCError::feature_not_available(
903                "SCScreenshotConfiguration",
904                "26.0",
905            ));
906        }
907        Ok(Self { ptr })
908    }
909
910    /// Set the output width in pixels
911    #[must_use]
912    #[allow(clippy::cast_possible_wrap)]
913    pub fn with_width(self, width: usize) -> Self {
914        unsafe {
915            crate::ffi::sc_screenshot_configuration_set_width(self.ptr, width as isize);
916        }
917        self
918    }
919
920    /// Set the output height in pixels
921    #[must_use]
922    #[allow(clippy::cast_possible_wrap)]
923    pub fn with_height(self, height: usize) -> Self {
924        unsafe {
925            crate::ffi::sc_screenshot_configuration_set_height(self.ptr, height as isize);
926        }
927        self
928    }
929
930    /// Set whether to show the cursor
931    #[must_use]
932    pub fn with_shows_cursor(self, shows_cursor: bool) -> Self {
933        unsafe {
934            crate::ffi::sc_screenshot_configuration_set_shows_cursor(self.ptr, shows_cursor);
935        }
936        self
937    }
938
939    /// Set the source rectangle (subset of capture area)
940    #[must_use]
941    pub fn with_source_rect(self, rect: crate::cg::CGRect) -> Self {
942        unsafe {
943            crate::ffi::sc_screenshot_configuration_set_source_rect(
944                self.ptr,
945                rect.origin.x,
946                rect.origin.y,
947                rect.size.width,
948                rect.size.height,
949            );
950        }
951        self
952    }
953
954    /// Set the destination rectangle (output area)
955    #[must_use]
956    pub fn with_destination_rect(self, rect: crate::cg::CGRect) -> Self {
957        unsafe {
958            crate::ffi::sc_screenshot_configuration_set_destination_rect(
959                self.ptr,
960                rect.origin.x,
961                rect.origin.y,
962                rect.size.width,
963                rect.size.height,
964            );
965        }
966        self
967    }
968
969    /// Set whether to ignore shadows
970    #[must_use]
971    pub fn with_ignore_shadows(self, ignore_shadows: bool) -> Self {
972        unsafe {
973            crate::ffi::sc_screenshot_configuration_set_ignore_shadows(self.ptr, ignore_shadows);
974        }
975        self
976    }
977
978    /// Set whether to ignore clipping
979    #[must_use]
980    pub fn with_ignore_clipping(self, ignore_clipping: bool) -> Self {
981        unsafe {
982            crate::ffi::sc_screenshot_configuration_set_ignore_clipping(self.ptr, ignore_clipping);
983        }
984        self
985    }
986
987    /// Set whether to include child windows
988    #[must_use]
989    pub fn with_include_child_windows(self, include_child_windows: bool) -> Self {
990        unsafe {
991            crate::ffi::sc_screenshot_configuration_set_include_child_windows(
992                self.ptr,
993                include_child_windows,
994            );
995        }
996        self
997    }
998
999    /// Set the display intent
1000    #[must_use]
1001    pub fn with_display_intent(self, display_intent: SCScreenshotDisplayIntent) -> Self {
1002        unsafe {
1003            crate::ffi::sc_screenshot_configuration_set_display_intent(
1004                self.ptr,
1005                display_intent as i32,
1006            );
1007        }
1008        self
1009    }
1010
1011    /// Set the dynamic range
1012    #[must_use]
1013    pub fn with_dynamic_range(self, dynamic_range: SCScreenshotDynamicRange) -> Self {
1014        unsafe {
1015            crate::ffi::sc_screenshot_configuration_set_dynamic_range(
1016                self.ptr,
1017                dynamic_range as i32,
1018            );
1019        }
1020        self
1021    }
1022
1023    /// Set the output file path.
1024    ///
1025    /// Accepts anything path-like (`&str`, `String`, `&Path`, `PathBuf`).
1026    ///
1027    /// # Errors
1028    ///
1029    /// Returns [`InvalidScreenshotPath`] and leaves the configuration unchanged
1030    /// if Foundation cannot represent the path.
1031    pub fn set_file_path(
1032        &mut self,
1033        path: impl AsRef<std::path::Path>,
1034    ) -> Result<&mut Self, InvalidScreenshotPath> {
1035        let path = path
1036            .as_ref()
1037            .to_str()
1038            .ok_or(InvalidScreenshotPath::NotUtf8)?;
1039        let c_path =
1040            std::ffi::CString::new(path).map_err(|_| InvalidScreenshotPath::InteriorNul)?;
1041        unsafe {
1042            crate::ffi::sc_screenshot_configuration_set_file_url(self.ptr, c_path.as_ptr());
1043        }
1044        Ok(self)
1045    }
1046
1047    /// Set the output file path (builder pattern).
1048    ///
1049    /// See [`set_file_path`](Self::set_file_path) for how invalid paths are
1050    /// handled.
1051    #[allow(clippy::missing_errors_doc)]
1052    pub fn with_file_path(
1053        mut self,
1054        path: impl AsRef<std::path::Path>,
1055    ) -> Result<Self, InvalidScreenshotPath> {
1056        self.set_file_path(path)?;
1057        Ok(self)
1058    }
1059
1060    /// Clear any previously configured output file path.
1061    #[must_use]
1062    pub fn without_file_path(self) -> Self {
1063        unsafe { crate::ffi::sc_screenshot_configuration_clear_file_url(self.ptr) };
1064        self
1065    }
1066
1067    /// Clear any previously configured output file path.
1068    pub fn clear_file_path(&mut self) -> &mut Self {
1069        unsafe { crate::ffi::sc_screenshot_configuration_clear_file_url(self.ptr) };
1070        self
1071    }
1072
1073    /// Get the configured output file path, if one was set.
1074    #[must_use]
1075    pub fn file_path(&self) -> Option<std::path::PathBuf> {
1076        owned_path(|| unsafe {
1077            crate::ffi::sc_screenshot_configuration_get_file_path_owned(self.ptr)
1078        })
1079    }
1080
1081    /// Get the configured output width in pixels.
1082    #[must_use]
1083    pub fn width(&self) -> usize {
1084        let width = unsafe { crate::ffi::sc_screenshot_configuration_get_width(self.ptr) };
1085        usize::try_from(width).unwrap_or(0)
1086    }
1087
1088    /// Get the configured output height in pixels.
1089    #[must_use]
1090    pub fn height(&self) -> usize {
1091        let height = unsafe { crate::ffi::sc_screenshot_configuration_get_height(self.ptr) };
1092        usize::try_from(height).unwrap_or(0)
1093    }
1094
1095    /// Whether the cursor will be drawn into the screenshot.
1096    #[must_use]
1097    pub fn shows_cursor(&self) -> bool {
1098        unsafe { crate::ffi::sc_screenshot_configuration_get_shows_cursor(self.ptr) }
1099    }
1100
1101    /// Get the source rectangle (the subset of the capture area to read).
1102    #[must_use]
1103    pub fn source_rect(&self) -> crate::cg::CGRect {
1104        read_rect(|x, y, w, h| unsafe {
1105            crate::ffi::sc_screenshot_configuration_get_source_rect(self.ptr, x, y, w, h);
1106        })
1107    }
1108
1109    /// Get the destination rectangle (where the source is drawn in the output).
1110    #[must_use]
1111    pub fn destination_rect(&self) -> crate::cg::CGRect {
1112        read_rect(|x, y, w, h| unsafe {
1113            crate::ffi::sc_screenshot_configuration_get_destination_rect(self.ptr, x, y, w, h);
1114        })
1115    }
1116
1117    /// Whether window shadows are excluded from the screenshot.
1118    #[must_use]
1119    pub fn ignore_shadows(&self) -> bool {
1120        unsafe { crate::ffi::sc_screenshot_configuration_get_ignore_shadows(self.ptr) }
1121    }
1122
1123    /// Whether clipping to the window bounds is ignored.
1124    #[must_use]
1125    pub fn ignore_clipping(&self) -> bool {
1126        unsafe { crate::ffi::sc_screenshot_configuration_get_ignore_clipping(self.ptr) }
1127    }
1128
1129    /// Whether child windows are included in the screenshot.
1130    #[must_use]
1131    pub fn include_child_windows(&self) -> bool {
1132        unsafe { crate::ffi::sc_screenshot_configuration_get_include_child_windows(self.ptr) }
1133    }
1134
1135    /// Get the display intent.
1136    ///
1137    /// Returns [`SCError::UnknownValue`] if the framework reported an intent
1138    /// this crate does not know about (a newer macOS adding a case).
1139    #[allow(clippy::missing_errors_doc)]
1140    pub fn display_intent(&self) -> Result<SCScreenshotDisplayIntent, SCError> {
1141        let mut raw = 0_i32;
1142        if !unsafe {
1143            crate::ffi::sc_screenshot_configuration_get_display_intent(self.ptr, &raw mut raw)
1144        } {
1145            return Err(SCError::feature_not_available(
1146                "SCScreenshotConfiguration.displayIntent",
1147                "26.0",
1148            ));
1149        }
1150        SCScreenshotDisplayIntent::from_raw(raw).ok_or_else(|| SCError::UnknownValue {
1151            type_name: "SCScreenshotDisplayIntent",
1152            raw: i64::from(raw),
1153        })
1154    }
1155
1156    /// Get the dynamic range.
1157    ///
1158    /// Returns [`SCError::UnknownValue`] if the framework reported a range
1159    /// this crate does not know about (a newer macOS adding a case).
1160    #[allow(clippy::missing_errors_doc)]
1161    pub fn dynamic_range(&self) -> Result<SCScreenshotDynamicRange, SCError> {
1162        let mut raw = 0_i32;
1163        if !unsafe {
1164            crate::ffi::sc_screenshot_configuration_get_dynamic_range(self.ptr, &raw mut raw)
1165        } {
1166            return Err(SCError::feature_not_available(
1167                "SCScreenshotConfiguration.dynamicRange",
1168                "26.0",
1169            ));
1170        }
1171        SCScreenshotDynamicRange::from_raw(raw).ok_or_else(|| SCError::UnknownValue {
1172            type_name: "SCScreenshotDynamicRange",
1173            raw: i64::from(raw),
1174        })
1175    }
1176
1177    /// Set the content type (output format) using `UTType` identifier
1178    ///
1179    /// Common identifiers include:
1180    /// - `"public.png"` - PNG format
1181    /// - `"public.jpeg"` - JPEG format
1182    /// - `"public.heic"` - HEIC format
1183    /// - `"public.tiff"` - TIFF format
1184    ///
1185    /// Use [`supported_content_types()`](Self::supported_content_types) to get
1186    /// available formats.
1187    ///
1188    /// # Errors
1189    ///
1190    /// Returns [`InteriorNulError`] if `identifier` contains an interior NUL
1191    /// byte. Valid `UTType` identifiers never contain NUL bytes.
1192    pub fn with_content_type(self, identifier: &str) -> Result<Self, InteriorNulError> {
1193        let c_id = std::ffi::CString::new(identifier).map_err(|_| InteriorNulError)?;
1194        unsafe {
1195            crate::ffi::sc_screenshot_configuration_set_content_type(self.ptr, c_id.as_ptr());
1196        }
1197        Ok(self)
1198    }
1199
1200    /// Get the current content type as `UTType` identifier
1201    ///
1202    /// Returns `None` when no content type is set or the identifier does not
1203    /// fit in the transfer buffer; the bridge reports overflow as failure
1204    /// rather than handing back a truncated identifier.
1205    #[must_use]
1206    pub fn content_type(&self) -> Option<String> {
1207        unsafe {
1208            crate::utils::ffi_string::ffi_string_from_buffer(
1209                UTTYPE_IDENTIFIER_BUFFER,
1210                |buffer, len| {
1211                    crate::ffi::sc_screenshot_configuration_get_content_type(
1212                        self.ptr,
1213                        buffer,
1214                        usize::try_from(len).unwrap_or(0),
1215                    )
1216                },
1217            )
1218        }
1219    }
1220
1221    /// Get the list of supported content types (`UTType` identifiers)
1222    ///
1223    /// Returns a list of `UTType` identifiers that can be used with
1224    /// [`with_content_type()`](Self::with_content_type).
1225    ///
1226    /// Common types include:
1227    /// - `"public.png"` - PNG format
1228    /// - `"public.jpeg"` - JPEG format
1229    /// - `"public.heic"` - HEIC format
1230    #[must_use]
1231    pub fn supported_content_types() -> Vec<String> {
1232        let count =
1233            unsafe { crate::ffi::sc_screenshot_configuration_get_supported_content_types_count() };
1234        (0..count)
1235            .filter_map(|i| unsafe {
1236                crate::utils::ffi_string::ffi_string_from_buffer(
1237                    UTTYPE_IDENTIFIER_BUFFER,
1238                    |buffer, len| {
1239                        crate::ffi::sc_screenshot_configuration_get_supported_content_type_at(
1240                            i,
1241                            buffer,
1242                            usize::try_from(len).unwrap_or(0),
1243                        )
1244                    },
1245                )
1246            })
1247            .collect()
1248    }
1249
1250    #[must_use]
1251    pub const fn as_ptr(&self) -> *const c_void {
1252        self.ptr
1253    }
1254}
1255
1256#[cfg(feature = "macos_26_0")]
1257impl std::fmt::Debug for SCScreenshotConfiguration {
1258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1259        f.debug_struct("SCScreenshotConfiguration")
1260            .field("content_type", &self.content_type())
1261            .finish_non_exhaustive()
1262    }
1263}
1264
1265#[cfg(feature = "macos_26_0")]
1266crate::utils::retained::sc_retained!(
1267    SCScreenshotConfiguration,
1268    field = ptr,
1269    release = crate::ffi::sc_screenshot_configuration_release,
1270);
1271
1272// SAFETY: `SCScreenshotConfiguration` wraps a mutable Objective-C object, but
1273// the wrapper owns it exclusively: there is no `Clone` and no constructor that
1274// hands out a second handle to the same instance. Mutation is therefore
1275// confined to `&mut self` / by-value builder methods, which Rust already makes
1276// exclusive, and the `&self` methods are pure getters. Objective-C reference
1277// counting is atomic, so `Drop` is safe from any thread.
1278#[cfg(feature = "macos_26_0")]
1279unsafe impl Send for SCScreenshotConfiguration {}
1280#[cfg(feature = "macos_26_0")]
1281unsafe impl Sync for SCScreenshotConfiguration {}
1282
1283// ============================================================================
1284// SCScreenshotOutput (macOS 26.0+)
1285// ============================================================================
1286
1287/// Output from advanced screenshot capture (macOS 26.0+)
1288///
1289/// Contains SDR and/or HDR images depending on the configuration,
1290/// and optionally the file URL where the image was saved.
1291#[cfg(feature = "macos_26_0")]
1292pub struct SCScreenshotOutput {
1293    ptr: *const c_void,
1294}
1295
1296#[cfg(feature = "macos_26_0")]
1297impl SCScreenshotOutput {
1298    pub(crate) fn from_ptr(ptr: *const c_void) -> Self {
1299        Self { ptr }
1300    }
1301
1302    /// Get the SDR image if available
1303    #[must_use]
1304    pub fn sdr_image(&self) -> Option<CGImage> {
1305        let ptr = unsafe { crate::ffi::sc_screenshot_output_get_sdr_image(self.ptr) };
1306        if ptr.is_null() {
1307            None
1308        } else {
1309            Some(unsafe { cgimage_from_retained_ptr(ptr) })
1310        }
1311    }
1312
1313    /// Get the HDR image if available
1314    #[must_use]
1315    pub fn hdr_image(&self) -> Option<CGImage> {
1316        let ptr = unsafe { crate::ffi::sc_screenshot_output_get_hdr_image(self.ptr) };
1317        if ptr.is_null() {
1318            None
1319        } else {
1320            Some(unsafe { cgimage_from_retained_ptr(ptr) })
1321        }
1322    }
1323
1324    /// Get the path the image was written to, if the configuration asked for
1325    /// file output.
1326    ///
1327    /// Read as an owned path from the bridge, so there is no fixed-size buffer
1328    /// to truncate long values.
1329    #[must_use]
1330    pub fn file_path(&self) -> Option<std::path::PathBuf> {
1331        owned_path(|| unsafe { crate::ffi::sc_screenshot_output_get_file_path_owned(self.ptr) })
1332    }
1333}
1334
1335#[cfg(feature = "macos_26_0")]
1336impl std::fmt::Debug for SCScreenshotOutput {
1337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1338        f.debug_struct("SCScreenshotOutput")
1339            .field(
1340                "sdr_image",
1341                &self.sdr_image().map(|i| (i.width(), i.height())),
1342            )
1343            .field(
1344                "hdr_image",
1345                &self.hdr_image().map(|i| (i.width(), i.height())),
1346            )
1347            .field("file_path", &self.file_path())
1348            .finish()
1349    }
1350}
1351
1352#[cfg(feature = "macos_26_0")]
1353crate::utils::retained::sc_retained!(
1354    SCScreenshotOutput,
1355    field = ptr,
1356    release = crate::ffi::sc_screenshot_output_release,
1357);
1358
1359// SAFETY: `SCScreenshotOutput` wraps an immutable Objective-C ScreenCaptureKit
1360// object whose reference counting is atomic; it is safe to send between and
1361// share across threads.
1362#[cfg(feature = "macos_26_0")]
1363unsafe impl Send for SCScreenshotOutput {}
1364#[cfg(feature = "macos_26_0")]
1365unsafe impl Sync for SCScreenshotOutput {}
1366
1367#[cfg(all(test, feature = "macos_26_0"))]
1368mod tests {
1369    use super::{SCScreenshotConfiguration, SCScreenshotDisplayIntent, SCScreenshotDynamicRange};
1370
1371    #[test]
1372    fn bridge_rejects_unknown_screenshot_raw_values() {
1373        let config = SCScreenshotConfiguration::new()
1374            .expect("macOS 26.0 or later")
1375            .with_display_intent(SCScreenshotDisplayIntent::Local)
1376            .with_dynamic_range(SCScreenshotDynamicRange::HDR);
1377        for raw in [3, -1, i32::MAX, i32::MIN] {
1378            let intent_applied = unsafe {
1379                crate::ffi::sc_screenshot_configuration_set_display_intent(config.ptr, raw)
1380            };
1381            let range_applied = unsafe {
1382                crate::ffi::sc_screenshot_configuration_set_dynamic_range(config.ptr, raw)
1383            };
1384            assert!(!intent_applied, "the bridge accepted display intent {raw}");
1385            assert!(!range_applied, "the bridge accepted dynamic range {raw}");
1386            assert_eq!(
1387                config.display_intent(),
1388                Ok(SCScreenshotDisplayIntent::Local)
1389            );
1390            assert_eq!(config.dynamic_range(), Ok(SCScreenshotDynamicRange::HDR));
1391        }
1392    }
1393}