Skip to main content

screencapturekit/shareable_content/
snapshot.rs

1//! Batched data snapshot of `SCShareableContent`.
2//!
3//! Returned by [`SCShareableContent::snapshot`]. Every field on every
4//! display / window / running application is fetched in **one** Swift FFI
5//! call per category (instead of `1 + N + 6N` for the per-element accessor
6//! pattern), saving ~70 µs on a system with ~220 windows.
7//!
8//! [`SCShareableContent::snapshot`]: super::SCShareableContent::snapshot
9
10#![allow(
11    clippy::cast_possible_wrap,
12    clippy::cast_sign_loss,
13    clippy::cast_possible_truncation
14)]
15
16use crate::cg::CGRect;
17use crate::ffi::{FFIApplicationData, FFIDisplayData, FFIWindowData};
18use std::ffi::c_void;
19use std::mem::MaybeUninit;
20
21// Static caps for the bridge's batch FFI scratch buffers. These are
22// intentionally generous — the bridge silently truncates above the cap, so
23// erring high is the safe default. A 256 KiB string pool covers ~256 windows
24// each with a 1 KiB title (real-world titles are <128 bytes typically).
25const MAX_DISPLAYS: usize = 64;
26const MAX_WINDOWS: usize = 4096;
27const MAX_APPS: usize = 1024;
28const STRING_POOL_BYTES: usize = 256 * 1024;
29
30/// Plain data describing one display.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct DisplaySnapshot {
33    pub display_id: u32,
34    pub width: i32,
35    pub height: i32,
36    pub frame: CGRect,
37}
38
39/// Plain data describing one running application.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ApplicationSnapshot {
42    pub process_id: i32,
43    pub bundle_identifier: String,
44    pub application_name: String,
45}
46
47/// Plain data describing one window.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct WindowSnapshot {
50    pub window_id: u32,
51    pub window_layer: i32,
52    pub is_on_screen: bool,
53    pub is_active: bool,
54    pub frame: CGRect,
55    pub title: Option<String>,
56    /// Index into [`ContentSnapshot::applications`], or `None` if the
57    /// window has no owning application or the owner wasn't returned in
58    /// the same snapshot batch.
59    pub owning_app_index: Option<usize>,
60}
61
62/// All shareable content collected in one batched FFI round-trip.
63#[derive(Debug, Default, Clone, PartialEq, Eq)]
64pub struct ContentSnapshot {
65    pub displays: Vec<DisplaySnapshot>,
66    pub applications: Vec<ApplicationSnapshot>,
67    pub windows: Vec<WindowSnapshot>,
68}
69
70impl ContentSnapshot {
71    /// Drive the three `_batch` Swift FFI functions and unpack their packed
72    /// `repr(C)` payloads into Rust-side data structures.
73    pub(crate) fn collect(content: *const c_void) -> Option<Self> {
74        if content.is_null() {
75            return None;
76        }
77
78        // SAFETY: each batch FFI function writes at most `max_*` packed
79        // entries into the supplied buffer. We size the buffers to the caps
80        // declared above and trust the bridge to honour them (the bridge
81        // takes the cap as a parameter and uses `min(actual, cap)`).
82        let displays = unsafe { collect_displays(content) };
83        let applications = unsafe { collect_applications(content) };
84        let windows = unsafe { collect_windows(content, applications.len()) };
85
86        Some(Self {
87            displays,
88            applications,
89            windows,
90        })
91    }
92}
93
94unsafe fn collect_displays(content: *const c_void) -> Vec<DisplaySnapshot> {
95    unsafe {
96        // Use `MaybeUninit` for the scratch buffer: the Swift bridge writes
97        // exactly `count` fully-initialised entries and we only ever read those.
98        // Building a `Vec<FFIDisplayData>` over uninitialised memory would be
99        // unsound because the element type has validity invariants.
100        let mut buffer: Vec<MaybeUninit<FFIDisplayData>> = Vec::with_capacity(MAX_DISPLAYS);
101        let written = crate::ffi::sc_shareable_content_get_displays_batch(
102            content,
103            buffer.as_mut_ptr().cast::<c_void>(),
104            MAX_DISPLAYS as isize,
105        );
106        if written <= 0 {
107            return Vec::new();
108        }
109        let count = (written as usize).min(MAX_DISPLAYS);
110
111        (0..count)
112            .map(|i| {
113                let d = buffer[i].assume_init();
114                DisplaySnapshot {
115                    display_id: d.display_id,
116                    width: d.width,
117                    height: d.height,
118                    frame: CGRect::new(d.frame.x, d.frame.y, d.frame.width, d.frame.height),
119                }
120            })
121            .collect()
122    }
123}
124
125unsafe fn collect_applications(content: *const c_void) -> Vec<ApplicationSnapshot> {
126    unsafe {
127        let mut packed: Vec<MaybeUninit<FFIApplicationData>> = Vec::with_capacity(MAX_APPS);
128        let mut strings: Vec<i8> = vec![0; STRING_POOL_BYTES];
129        let mut strings_used: isize = 0;
130
131        let written = crate::ffi::sc_shareable_content_get_applications_batch(
132            content,
133            packed.as_mut_ptr().cast::<c_void>(),
134            MAX_APPS as isize,
135            strings.as_mut_ptr(),
136            STRING_POOL_BYTES as isize,
137            &mut strings_used,
138        );
139        if written <= 0 {
140            return Vec::new();
141        }
142        let count = (written as usize).min(MAX_APPS);
143
144        let pool: &[u8] = std::slice::from_raw_parts(
145            strings.as_ptr().cast::<u8>(),
146            (strings_used as usize).min(STRING_POOL_BYTES),
147        );
148
149        (0..count)
150            .map(|i| {
151                let app = packed[i].assume_init();
152                ApplicationSnapshot {
153                    process_id: app.process_id,
154                    bundle_identifier: read_string(
155                        pool,
156                        app.bundle_id_offset,
157                        app.bundle_id_length,
158                    ),
159                    application_name: read_string(pool, app.app_name_offset, app.app_name_length),
160                }
161            })
162            .collect()
163    }
164}
165
166unsafe fn collect_windows(content: *const c_void, app_count_hint: usize) -> Vec<WindowSnapshot> {
167    unsafe {
168        let mut packed: Vec<MaybeUninit<FFIWindowData>> = Vec::with_capacity(MAX_WINDOWS);
169        let mut strings: Vec<i8> = vec![0; STRING_POOL_BYTES];
170        let mut strings_used: isize = 0;
171        // The Swift bridge also retains app pointers into this buffer for the
172        // owning-app index lookup. We don't need to keep the pointers (we already
173        // have `applications` from the previous batch call), but we still have to
174        // accept and release them so the bridge balances its retain.
175        let app_cap = MAX_APPS.max(app_count_hint);
176        let mut app_pointers: Vec<*const c_void> = vec![std::ptr::null(); app_cap];
177        let mut app_count: isize = 0;
178
179        let written = crate::ffi::sc_shareable_content_get_windows_batch(
180            content,
181            packed.as_mut_ptr().cast::<c_void>(),
182            MAX_WINDOWS as isize,
183            strings.as_mut_ptr(),
184            STRING_POOL_BYTES as isize,
185            &mut strings_used,
186            app_pointers.as_mut_ptr(),
187            app_cap as isize,
188            &mut app_count,
189        );
190
191        // Release the SCRunningApplication pointers the bridge retained for us;
192        // we don't need them in the snapshot path (the snapshot is plain data).
193        let returned_apps = (app_count as usize).min(app_cap);
194        for &ptr in &app_pointers[..returned_apps] {
195            if !ptr.is_null() {
196                crate::ffi::sc_running_application_release(ptr);
197            }
198        }
199
200        if written <= 0 {
201            return Vec::new();
202        }
203        let count = (written as usize).min(MAX_WINDOWS);
204
205        let pool: &[u8] = std::slice::from_raw_parts(
206            strings.as_ptr().cast::<u8>(),
207            (strings_used as usize).min(STRING_POOL_BYTES),
208        );
209
210        (0..count)
211            .map(|i| {
212                let w = packed[i].assume_init();
213                let title = if w.title_length == 0 {
214                    None
215                } else {
216                    let s = read_string(pool, w.title_offset, w.title_length);
217                    if s.is_empty() {
218                        None
219                    } else {
220                        Some(s)
221                    }
222                };
223                WindowSnapshot {
224                    window_id: w.window_id,
225                    window_layer: w.window_layer,
226                    is_on_screen: w.is_on_screen,
227                    is_active: w.is_active,
228                    frame: CGRect::new(w.frame.x, w.frame.y, w.frame.width, w.frame.height),
229                    title,
230                    owning_app_index: if w.owning_app_index < 0 {
231                        None
232                    } else {
233                        Some(w.owning_app_index as usize)
234                    },
235                }
236            })
237            .collect()
238    }
239}
240
241fn read_string(pool: &[u8], offset: u32, length: u32) -> String {
242    read_str(pool, offset, length).map_or_else(String::new, str::to_owned)
243}
244
245/// Zero-copy view of a string slice in the shared pool.
246///
247/// Returns `None` if the `[offset, offset+length)` range is out of bounds or
248/// the bytes are not valid UTF-8. The borrow is tied to the pool, so callers
249/// only allocate when they need an owned value.
250fn read_str(pool: &[u8], offset: u32, length: u32) -> Option<&str> {
251    let start = offset as usize;
252    let end = start.saturating_add(length as usize);
253    let bytes = pool.get(start..end)?;
254    std::str::from_utf8(bytes).ok()
255}