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).
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::collections::HashMap;
19use std::ffi::c_void;
20use std::mem::MaybeUninit;
21
22// Caps for the bridge's batch FFI scratch buffers. The bridge silently
23// truncates above the cap (`count = min(actual, cap)`), so a saturated count
24// is surfaced to callers through [`SnapshotTruncation`] rather than swallowed.
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    /// The window title, or `None` when the window has no title **or** its
56    /// title did not fit in the batch string pool. See
57    /// [`ContentSnapshot::string_pool_used`] for how to detect pool pressure.
58    pub title: Option<String>,
59    /// Index into [`ContentSnapshot::applications`], or `None` if the
60    /// window has no owning application or the owner wasn't returned in
61    /// the same snapshot batch. Always in range for `applications`.
62    pub owning_app_index: Option<usize>,
63}
64
65/// Which categories of a [`ContentSnapshot`] may have been cut short by the
66/// batch FFI scratch-buffer caps.
67///
68/// A flag is set when the bridge returned exactly as many entries as the
69/// buffer could hold, which means the real list is *at least* that long and
70/// may be longer. It is deliberately conservative: an exactly-full system
71/// reports `true` even though nothing was actually dropped.
72#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
73pub struct SnapshotTruncation {
74    /// The display list saturated the 64-entry cap.
75    pub displays: bool,
76    /// The window list saturated the 4096-entry cap.
77    pub windows: bool,
78    /// The application list saturated the 1024-entry cap.
79    pub applications: bool,
80}
81
82impl SnapshotTruncation {
83    /// Whether any category may have been truncated.
84    #[must_use]
85    pub const fn any(self) -> bool {
86        self.displays || self.windows || self.applications
87    }
88}
89
90/// All shareable content collected in one batched FFI round-trip.
91#[derive(Debug, Default, Clone, PartialEq, Eq)]
92pub struct ContentSnapshot {
93    pub displays: Vec<DisplaySnapshot>,
94    pub applications: Vec<ApplicationSnapshot>,
95    pub windows: Vec<WindowSnapshot>,
96    /// Which lists may have been cut short by the scratch-buffer caps.
97    pub truncation: SnapshotTruncation,
98    /// Bytes of the batch string pool consumed by window titles and
99    /// application names.
100    ///
101    /// The bridge appends each string greedily and **skips** any string that
102    /// does not fit in the remaining space (surfacing as `title: None` / an
103    /// empty name) rather than partially writing it. Compare against
104    /// [`string_pool_capacity`](Self::string_pool_capacity) to judge whether
105    /// names may have been dropped.
106    pub string_pool_used: usize,
107}
108
109impl ContentSnapshot {
110    /// Total capacity of the batch string pool, in bytes.
111    #[must_use]
112    pub const fn string_pool_capacity() -> usize {
113        STRING_POOL_BYTES
114    }
115
116    /// Drive the three `_batch` Swift FFI functions and unpack their packed
117    /// `repr(C)` payloads into Rust-side data structures.
118    ///
119    /// Returns `None` only when `content` is null. Buffer saturation is
120    /// reported through [`ContentSnapshot::truncation`], not as `None`.
121    pub(crate) fn collect(content: *const c_void) -> Option<Self> {
122        if content.is_null() {
123            return None;
124        }
125
126        // One scratch pool, reused across the two string-bearing batch calls.
127        // Each call copies everything it needs into owned `String`s before
128        // returning, so the buffer is free to be overwritten afterwards.
129        let mut pool = StringPool::new();
130
131        // SAFETY: each batch FFI function writes at most `max_*` packed
132        // entries into the supplied buffer and reports how many it wrote.
133        // We only ever read back that many.
134        let (displays, displays_truncated) = unsafe { collect_displays(content) };
135        let (applications, apps_truncated, apps_pool_used) =
136            unsafe { collect_applications(content, &mut pool) };
137        let (windows, windows_truncated, windows_pool_used) =
138            unsafe { collect_windows(content, &applications, &mut pool) };
139
140        Some(Self {
141            displays,
142            applications,
143            windows,
144            truncation: SnapshotTruncation {
145                displays: displays_truncated,
146                windows: windows_truncated,
147                applications: apps_truncated,
148            },
149            string_pool_used: apps_pool_used.max(windows_pool_used),
150        })
151    }
152}
153
154/// Reusable uninitialised scratch buffer for the bridge's string pool.
155///
156/// Deliberately **not** zero-filled: the bridge writes `[0, used)` and reports
157/// `used`, and nothing ever reads past that, so zeroing 256 KiB per snapshot
158/// would be pure waste.
159struct StringPool {
160    buf: Vec<MaybeUninit<u8>>,
161}
162
163impl StringPool {
164    fn new() -> Self {
165        Self {
166            buf: Vec::with_capacity(STRING_POOL_BYTES),
167        }
168    }
169
170    fn as_mut_ptr(&mut self) -> *mut i8 {
171        self.buf.as_mut_ptr().cast::<i8>()
172    }
173
174    /// Borrow the prefix the bridge reported as written.
175    ///
176    /// # Safety
177    ///
178    /// `used` bytes starting at the buffer base must have been initialised by
179    /// the bridge call that produced `used`.
180    unsafe fn initialised(&self, used: usize) -> &[u8] {
181        let used = used.min(STRING_POOL_BYTES);
182        // SAFETY: the caller guarantees `[0, used)` was written by the bridge,
183        // and `used` is clamped to the allocation size.
184        unsafe { std::slice::from_raw_parts(self.buf.as_ptr().cast::<u8>(), used) }
185    }
186}
187
188/// Read the `i`th packed entry the bridge wrote into an uninitialised buffer.
189///
190/// Takes the base pointer rather than a slice reference: the buffer is built
191/// with `Vec::with_capacity`, so `len()` is 0. Coercing it to `&[MaybeUninit<T>]`
192/// would yield a slice spanning zero bytes, and offsetting that pointer past
193/// the first element is out of bounds for its provenance. `Vec::as_ptr`
194/// carries the whole allocation.
195///
196/// # Safety
197///
198/// `base` must point at the start of a buffer with capacity for at least
199/// `i + 1` entries, and the bridge must have initialised entry `i`.
200unsafe fn packed_at<T: Copy>(base: *const MaybeUninit<T>, i: usize) -> T {
201    // SAFETY: the caller guarantees the bridge initialised entry `i`, and `i`
202    // is bounded by the count the bridge reported (capped at capacity).
203    unsafe { base.add(i).read().assume_init() }
204}
205
206/// Returns `(displays, possibly_truncated)`.
207unsafe fn collect_displays(content: *const c_void) -> (Vec<DisplaySnapshot>, bool) {
208    unsafe {
209        // `MaybeUninit` because the bridge writes exactly `count` fully
210        // initialised entries; materialising a `Vec<FFIDisplayData>` over
211        // uninitialised memory would be unsound (the element type has
212        // validity invariants).
213        let mut buffer: Vec<MaybeUninit<FFIDisplayData>> = Vec::with_capacity(MAX_DISPLAYS);
214        let written = crate::ffi::sc_shareable_content_get_displays_batch(
215            content,
216            buffer.as_mut_ptr().cast::<c_void>(),
217            MAX_DISPLAYS as isize,
218        );
219        if written <= 0 {
220            return (Vec::new(), false);
221        }
222        let count = (written as usize).min(MAX_DISPLAYS);
223
224        let displays = (0..count)
225            .map(|i| {
226                let d = packed_at(buffer.as_ptr(), i);
227                DisplaySnapshot {
228                    display_id: d.display_id,
229                    width: d.width,
230                    height: d.height,
231                    frame: CGRect::new(d.frame.x, d.frame.y, d.frame.width, d.frame.height),
232                }
233            })
234            .collect();
235
236        (displays, count == MAX_DISPLAYS)
237    }
238}
239
240/// Returns `(applications, possibly_truncated, string_pool_used)`.
241unsafe fn collect_applications(
242    content: *const c_void,
243    pool: &mut StringPool,
244) -> (Vec<ApplicationSnapshot>, bool, usize) {
245    unsafe {
246        let mut packed: Vec<MaybeUninit<FFIApplicationData>> = Vec::with_capacity(MAX_APPS);
247        let mut strings_used: isize = 0;
248
249        let written = crate::ffi::sc_shareable_content_get_applications_batch(
250            content,
251            packed.as_mut_ptr().cast::<c_void>(),
252            MAX_APPS as isize,
253            pool.as_mut_ptr(),
254            STRING_POOL_BYTES as isize,
255            &raw mut strings_used,
256        );
257        if written <= 0 {
258            return (Vec::new(), false, 0);
259        }
260        let count = (written as usize).min(MAX_APPS);
261        let used = (strings_used.max(0) as usize).min(STRING_POOL_BYTES);
262        let bytes = pool.initialised(used);
263
264        let apps = (0..count)
265            .map(|i| {
266                let app = packed_at(packed.as_ptr(), i);
267                ApplicationSnapshot {
268                    process_id: app.process_id,
269                    bundle_identifier: read_string(
270                        bytes,
271                        app.bundle_id_offset,
272                        app.bundle_id_length,
273                    ),
274                    application_name: read_string(bytes, app.app_name_offset, app.app_name_length),
275                }
276            })
277            .collect();
278
279        (apps, count == MAX_APPS, used)
280    }
281}
282
283/// Returns `(windows, possibly_truncated, string_pool_used)`.
284unsafe fn collect_windows(
285    content: *const c_void,
286    applications: &[ApplicationSnapshot],
287    pool: &mut StringPool,
288) -> (Vec<WindowSnapshot>, bool, usize) {
289    unsafe {
290        let mut packed: Vec<MaybeUninit<FFIWindowData>> = Vec::with_capacity(MAX_WINDOWS);
291        let mut strings_used: isize = 0;
292
293        let written = crate::ffi::sc_shareable_content_get_windows_batch(
294            content,
295            packed.as_mut_ptr().cast::<c_void>(),
296            MAX_WINDOWS as isize,
297            pool.as_mut_ptr(),
298            STRING_POOL_BYTES as isize,
299            &raw mut strings_used,
300        );
301
302        if written <= 0 {
303            return (Vec::new(), false, 0);
304        }
305        let count = (written as usize).min(MAX_WINDOWS);
306        let used = (strings_used.max(0) as usize).min(STRING_POOL_BYTES);
307        let bytes = pool.initialised(used);
308        let app_indices: HashMap<i32, usize> = applications
309            .iter()
310            .enumerate()
311            .map(|(index, app)| (app.process_id, index))
312            .collect();
313
314        let windows = (0..count)
315            .map(|i| {
316                let w = packed_at(packed.as_ptr(), i);
317                let title = if w.title_length == 0 {
318                    None
319                } else {
320                    let s = read_string(bytes, w.title_offset, w.title_length);
321                    if s.is_empty() {
322                        None
323                    } else {
324                        Some(s)
325                    }
326                };
327                let owning_app_index = app_indices.get(&w.owning_app_process_id).copied();
328                WindowSnapshot {
329                    window_id: w.window_id,
330                    window_layer: w.window_layer,
331                    is_on_screen: w.is_on_screen,
332                    is_active: w.is_active,
333                    frame: CGRect::new(w.frame.x, w.frame.y, w.frame.width, w.frame.height),
334                    title,
335                    owning_app_index,
336                }
337            })
338            .collect();
339
340        (windows, count == MAX_WINDOWS, used)
341    }
342}
343
344fn read_string(pool: &[u8], offset: u32, length: u32) -> String {
345    read_str(pool, offset, length).map_or_else(String::new, str::to_owned)
346}
347
348/// Zero-copy view of a string slice in the shared pool.
349///
350/// Returns `None` if the `[offset, offset+length)` range is out of bounds or
351/// the bytes are not valid UTF-8. The borrow is tied to the pool, so callers
352/// only allocate when they need an owned value.
353fn read_str(pool: &[u8], offset: u32, length: u32) -> Option<&str> {
354    let start = offset as usize;
355    let end = start.checked_add(length as usize)?;
356    let bytes = pool.get(start..end)?;
357    std::str::from_utf8(bytes).ok()
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::ffi::FFIRect;
364
365    /// Regression test for the `Vec::with_capacity` + `buffer[i]` panic:
366    /// `with_capacity` leaves `len() == 0`, so indexing panicked for every
367    /// entry the bridge wrote. `packed_at` reads through the pointer instead.
368    #[test]
369    fn packed_at_reads_bridge_written_entries_from_a_len_zero_vec() {
370        let mut buffer: Vec<MaybeUninit<FFIDisplayData>> = Vec::with_capacity(MAX_DISPLAYS);
371        assert_eq!(buffer.len(), 0, "with_capacity must not set len");
372
373        let written = [
374            FFIDisplayData {
375                display_id: 7,
376                width: 1920,
377                height: 1080,
378                frame: FFIRect {
379                    x: 0.0,
380                    y: 0.0,
381                    width: 1920.0,
382                    height: 1080.0,
383                },
384            },
385            FFIDisplayData {
386                display_id: 9,
387                width: 800,
388                height: 600,
389                frame: FFIRect {
390                    x: 1.0,
391                    y: 2.0,
392                    width: 800.0,
393                    height: 600.0,
394                },
395            },
396        ];
397        unsafe {
398            std::ptr::copy_nonoverlapping(
399                written.as_ptr(),
400                buffer.as_mut_ptr().cast::<FFIDisplayData>(),
401                written.len(),
402            );
403        }
404
405        let read: Vec<u32> = (0..written.len())
406            .map(|i| unsafe { packed_at(buffer.as_ptr(), i) }.display_id)
407            .collect();
408        assert_eq!(read, vec![7, 9]);
409    }
410
411    #[test]
412    fn string_pool_reads_only_the_written_prefix() {
413        let mut pool = StringPool::new();
414        let src = b"hello world";
415        unsafe {
416            std::ptr::copy_nonoverlapping(src.as_ptr(), pool.as_mut_ptr().cast::<u8>(), src.len());
417        }
418        let bytes = unsafe { pool.initialised(src.len()) };
419        assert_eq!(bytes, src);
420        assert_eq!(read_str(bytes, 0, 5), Some("hello"));
421        assert_eq!(read_str(bytes, 6, 5), Some("world"));
422        // Past the written prefix -> None, never a read of uninitialised memory.
423        assert_eq!(read_str(bytes, 6, 99), None);
424        assert_eq!(read_str(bytes, u32::MAX, 1), None);
425    }
426
427    #[test]
428    fn truncation_any_reflects_individual_flags() {
429        assert!(!SnapshotTruncation::default().any());
430        assert!(SnapshotTruncation {
431            windows: true,
432            ..Default::default()
433        }
434        .any());
435        assert!(SnapshotTruncation {
436            displays: true,
437            ..Default::default()
438        }
439        .any());
440        assert!(SnapshotTruncation {
441            applications: true,
442            ..Default::default()
443        }
444        .any());
445    }
446
447    #[test]
448    fn collect_rejects_null_content() {
449        assert!(ContentSnapshot::collect(std::ptr::null()).is_none());
450    }
451}