Skip to main content

screen_app/
click_capture.rs

1//! Click telemetry capture (ED.17 / ISS-16 / M-EDIT).
2//!
3//! Records *where the user clicked* during a recording so the editor can drive
4//! the already-tested auto-zoom generator
5//! ([`auto_zoom_segments`](edit::telemetry::auto_zoom_segments)) and the ED.19
6//! click ripples. It is the companion to [`cursor_capture`](crate::cursor_capture):
7//! that module captures the cursor *position* track with no permission; this
8//! one captures the *click* log, which needs more.
9//!
10//! ## Why a tap, and why it's runtime-only
11//!
12//! Unlike the cursor position (readable with `CGEventCreate(NULL)`), clicks
13//! must be observed through a **`CGEventTap`** — a listen-only system event
14//! tap for left/right mouse-down. A tap requires the **Input-Monitoring**
15//! permission (the OS prompts the user; it cannot be granted in CI/headless)
16//! and a **`CFRunLoop`** to pump the tap's mach-port source. So the live tap is
17//! *runtime-only*: there is no automated test for the capture itself. What
18//! *is* exhaustively tested is the pure arithmetic either side of it —
19//! [`samples_to_clicks`] (timestamp → project-frame mapping) and the
20//! `RecordingState` handoff — plus the non-macOS stub, so the whole module
21//! compiles + gate-greens on every OS.
22//!
23//! ## Graceful degradation
24//!
25//! If the tap can't be created (permission not yet granted) the worker logs
26//! and exits cleanly — the recording proceeds, the editor simply gets no
27//! click log (auto-zoom stays available as a manual tool). Capture never
28//! blocks or fails a recording.
29
30use std::time::Duration;
31
32use edit::ClickEvent;
33
34/// Resample timestamped clicks (`(elapsed_since_start, x, y)`, normalized to
35/// the captured frame, sorted by time) onto the project frame grid: each
36/// click's frame is `floor(elapsed_secs · project_fps)`. Unlike the cursor
37/// track, every click is kept (two clicks one frame apart are two real events
38/// — the auto-zoom clusterer merges them by time itself). Pure.
39#[must_use]
40#[allow(
41    clippy::cast_possible_truncation,
42    clippy::cast_sign_loss,
43    reason = "elapsed·fps is a non-negative frame index well under 2^52; the f64→u64 cast is floor of a clamped-non-negative value"
44)]
45pub fn samples_to_clicks(samples: &[(Duration, f32, f32)], project_fps: u32) -> Vec<ClickEvent> {
46    let fps = f64::from(project_fps.max(1));
47    samples
48        .iter()
49        .map(|&(t, x, y)| {
50            let frame = (t.as_secs_f64() * fps).max(0.0) as u64;
51            ClickEvent::new(frame, x, y)
52        })
53        .collect()
54}
55
56#[cfg(target_os = "macos")]
57mod imp {
58    #![allow(
59        unsafe_code,
60        reason = "CGEventTap + CFRunLoop are C FFI: CGEventTapCreate is unsafe (takes a raw callback + user_info), the tap callback is `extern \"C-unwind\"`, and unblocking the worker reads the run loop through a raw pointer. Each unsafe site documents its own SAFETY invariant. The wider `unsafe_code = warn` is workspace-wide; this FFI module scopes the justification."
61    )]
62
63    use std::ffi::c_void;
64    use std::ptr::NonNull;
65    use std::sync::atomic::{AtomicUsize, Ordering};
66    use std::sync::{Arc, Mutex};
67    use std::thread::JoinHandle;
68    use std::time::{Duration, Instant};
69
70    use objc2_core_foundation::{CFMachPort, CFRunLoop, kCFRunLoopCommonModes};
71    use objc2_core_graphics::{
72        CGEvent, CGEventMask, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement,
73        CGEventTapProxy, CGEventType,
74    };
75
76    use crate::cursor_capture::normalize_cursor_to_frame;
77
78    /// State the C tap callback writes into. Lives behind an [`Arc`] held by
79    /// both [`ClickTap`] and its worker thread; the raw pointer handed to
80    /// `CGEventTapCreate` borrows it (the worker's `Arc` clone keeps it alive
81    /// for the whole run-loop lifetime).
82    struct Shared {
83        /// Captured-display rect (CG points) clicks are normalized against.
84        rect: (f64, f64, f64, f64),
85        /// Recording start, for the per-click elapsed timestamp.
86        start: Instant,
87        /// Accumulated `(elapsed, x, y)` clicks, drained at stop.
88        clicks: Mutex<Vec<(Duration, f32, f32)>>,
89    }
90
91    /// The C event-tap callback: on a left/right mouse-down, record the
92    /// normalized click position + elapsed time, then pass the event through
93    /// unchanged (listen-only never mutates the stream).
94    ///
95    /// # Safety
96    ///
97    /// `user_info` is the `Shared` pointer passed to `CGEventTapCreate`; it is
98    /// valid for the run loop's lifetime (the worker holds an `Arc`). `event`
99    /// is a live `CGEvent` for the duration of the call.
100    unsafe extern "C-unwind" fn tap_callback(
101        _proxy: CGEventTapProxy,
102        etype: CGEventType,
103        event: NonNull<CGEvent>,
104        user_info: *mut c_void,
105    ) -> *mut CGEvent {
106        if !user_info.is_null()
107            && (etype == CGEventType::LeftMouseDown || etype == CGEventType::RightMouseDown)
108        {
109            // SAFETY: `user_info` is the `Arc<Shared>` raw pointer, alive for
110            // the run loop. We borrow, never drop, it.
111            let shared = unsafe { &*(user_info.cast::<Shared>()) };
112            let ev = unsafe { event.as_ref() };
113            let p = CGEvent::location(Some(ev));
114            let (x, y) = normalize_cursor_to_frame((p.x, p.y), shared.rect);
115            if let Ok(mut clicks) = shared.clicks.lock() {
116                clicks.push((shared.start.elapsed(), x, y));
117            }
118        }
119        event.as_ptr()
120    }
121
122    /// Captures mouse-down clicks through a listen-only `CGEventTap` for the
123    /// duration of a recording (ED.17 / ISS-16). The tap's mach-port source is
124    /// pumped on a dedicated worker thread's `CFRunLoop`; [`Self::stop`] stops
125    /// that run loop and drains the clicks.
126    pub struct ClickTap {
127        shared: Arc<Shared>,
128        /// The worker's `CFRunLoop` pointer, as a `usize` address (`0` until
129        /// published) — `stop()` calls the thread-safe `CFRunLoopStop` on it
130        /// to unblock the worker. A pointer (not the `CFRetained`, which is
131        /// `!Send`) keeps `ClickTap` — and thus the shared `RecordingState` —
132        /// `Send + Sync`; the run loop stays alive on the worker thread for
133        /// the whole capture, so the address is valid until we join.
134        runloop_ptr: Arc<AtomicUsize>,
135        handle: Option<JoinHandle<()>>,
136    }
137
138    impl ClickTap {
139        /// Start capturing, normalizing each click to `rect` (`(origin_x,
140        /// origin_y, width, height)` in CG points — the captured display).
141        ///
142        /// Spawns the run-loop worker. If the tap can't be created (no
143        /// Input-Monitoring permission), the worker logs + exits and capture
144        /// degrades to an empty log; the recording is unaffected.
145        #[must_use]
146        pub fn start(rect: (f64, f64, f64, f64)) -> Self {
147            let shared = Arc::new(Shared {
148                rect,
149                start: Instant::now(),
150                clicks: Mutex::new(Vec::new()),
151            });
152            let runloop_ptr = Arc::new(AtomicUsize::new(0));
153            let shared_thread = Arc::clone(&shared);
154            let runloop_ptr_thread = Arc::clone(&runloop_ptr);
155
156            let handle = std::thread::spawn(move || {
157                // The Arc clone keeps `Shared` alive while the callback may
158                // fire; the raw pointer borrows it.
159                let user_info = Arc::as_ptr(&shared_thread).cast_mut().cast::<c_void>();
160                // Mask bit per event type is `1 << type`.
161                let mask: CGEventMask = (1u64 << CGEventType::LeftMouseDown.0)
162                    | (1u64 << CGEventType::RightMouseDown.0);
163
164                // SAFETY: a correct callback + a valid (borrowed) user_info.
165                let tap = unsafe {
166                    CGEvent::tap_create(
167                        CGEventTapLocation::SessionEventTap,
168                        CGEventTapPlacement::HeadInsertEventTap,
169                        CGEventTapOptions::ListenOnly,
170                        mask,
171                        Some(tap_callback),
172                        user_info,
173                    )
174                };
175                let Some(tap) = tap else {
176                    tracing::warn!(
177                        "click tap unavailable (Input-Monitoring permission not granted?); \
178                         recording proceeds without a click log"
179                    );
180                    return;
181                };
182                let Some(source) = CFMachPort::new_run_loop_source(None, Some(&tap), 0) else {
183                    tracing::warn!("CFMachPort run-loop source creation failed; no click log");
184                    return;
185                };
186                let Some(rl) = CFRunLoop::current() else {
187                    return;
188                };
189                // SAFETY: `kCFRunLoopCommonModes` is a CF constant string.
190                let mode = unsafe { kCFRunLoopCommonModes };
191                rl.add_source(Some(&source), mode);
192                CGEvent::tap_enable(&tap, true);
193                // Publish the run loop's address so `stop()` can unblock us.
194                // `rl` (the `CFRetained`) stays on this thread's stack — and CF
195                // owns the per-thread run loop for the thread's lifetime — so
196                // the pointer is valid until we join.
197                let rl_ptr = (&raw const *rl) as usize;
198                runloop_ptr_thread.store(rl_ptr, Ordering::SeqCst);
199                CFRunLoop::run();
200                // Keep the tap + source + run loop alive until the loop returns.
201                drop(source);
202                drop(tap);
203                drop(rl);
204            });
205
206            Self {
207                shared,
208                runloop_ptr,
209                handle: Some(handle),
210            }
211        }
212
213        /// Stop capturing and return the timestamped clicks (in time order).
214        /// Feed them to [`super::samples_to_clicks`] for the project log.
215        #[must_use]
216        pub fn stop(mut self) -> Vec<(Duration, f32, f32)> {
217            self.stop_runloop();
218            if let Some(h) = self.handle.take() {
219                let _ = h.join();
220            }
221            std::mem::take(
222                &mut *self
223                    .shared
224                    .clicks
225                    .lock()
226                    .unwrap_or_else(std::sync::PoisonError::into_inner),
227            )
228        }
229
230        /// Stop the worker's run loop, waiting briefly for it to be published
231        /// if `stop` raced an only-just-started worker (bounded so a worker
232        /// that exited early — no permission — can never hang the caller).
233        fn stop_runloop(&self) {
234            let mut waited = Duration::ZERO;
235            let step = Duration::from_millis(5);
236            loop {
237                // Take the address exactly once (swap to 0) so Drop after
238                // `stop()` is a no-op.
239                let addr = self.runloop_ptr.swap(0, Ordering::SeqCst);
240                if addr != 0 {
241                    // SAFETY: the worker holds the run loop alive (blocked in
242                    // `CFRunLoop::run`) until this `CFRunLoopStop` returns it;
243                    // `CFRunLoopStop` is thread-safe.
244                    let rl = unsafe { &*(addr as *const CFRunLoop) };
245                    rl.stop();
246                    return;
247                }
248                // Worker finished (early-return) → nothing to stop.
249                if self.handle.as_ref().is_none_or(JoinHandle::is_finished) {
250                    return;
251                }
252                if waited >= Duration::from_secs(2) {
253                    return;
254                }
255                std::thread::sleep(step);
256                waited += step;
257            }
258        }
259    }
260
261    impl Drop for ClickTap {
262        fn drop(&mut self) {
263            // If `stop()` already ran, handle is None and this is a no-op;
264            // otherwise stop the loop + join so the worker can't outlive us.
265            self.stop_runloop();
266            if let Some(h) = self.handle.take() {
267                let _ = h.join();
268            }
269        }
270    }
271}
272
273#[cfg(not(target_os = "macos"))]
274mod imp {
275    use std::time::Duration;
276
277    /// Non-macOS stub: click capture is macOS-first (ED.17 / ISS-16). The
278    /// editor simply gets no click log on other platforms.
279    pub struct ClickTap;
280
281    impl ClickTap {
282        /// No-op start — no click capture off macOS.
283        #[must_use]
284        pub fn start(_rect: (f64, f64, f64, f64)) -> Self {
285            Self
286        }
287
288        /// No-op stop — always an empty click log off macOS.
289        #[must_use]
290        pub fn stop(self) -> Vec<(Duration, f32, f32)> {
291            Vec::new()
292        }
293    }
294}
295
296pub use imp::ClickTap;
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn samples_to_clicks_maps_each_click_to_its_frame() {
304        // 30 fps → 1 frame per 1/30 s.
305        let samples = [
306            (Duration::from_millis(0), 0.1, 0.2),
307            (Duration::from_millis(40), 0.5, 0.6),   // frame 1
308            (Duration::from_millis(1000), 0.9, 0.9), // frame 30
309        ];
310        let clicks = samples_to_clicks(&samples, 30);
311        assert_eq!(clicks.len(), 3, "every click is kept");
312        assert_eq!(clicks[0].frame, 0);
313        assert!((clicks[0].x - 0.1).abs() < 1e-6 && (clicks[0].y - 0.2).abs() < 1e-6);
314        assert_eq!(clicks[1].frame, 1);
315        assert_eq!(clicks[2].frame, 30);
316    }
317
318    #[test]
319    fn samples_to_clicks_keeps_two_clicks_in_one_frame() {
320        // Two distinct clicks both inside frame 0 stay as two events (the
321        // auto-zoom clusterer, not this resampler, merges by time).
322        let samples = [
323            (Duration::from_millis(2), 0.2, 0.2),
324            (Duration::from_millis(8), 0.8, 0.8),
325        ];
326        let clicks = samples_to_clicks(&samples, 30);
327        assert_eq!(clicks.len(), 2);
328        assert_eq!(clicks[0].frame, 0);
329        assert_eq!(clicks[1].frame, 0);
330    }
331
332    #[test]
333    fn samples_to_clicks_empty_is_empty() {
334        assert!(samples_to_clicks(&[], 30).is_empty());
335    }
336
337    #[test]
338    fn samples_to_clicks_feeds_auto_zoom() {
339        // End-to-end shape check: captured clicks → ClickEvents → the existing
340        // auto-zoom generator produces a zoom (proves the wiring contract).
341        let samples = [(Duration::from_millis(1000), 0.4, 0.6)];
342        let clicks = samples_to_clicks(&samples, 30);
343        let zooms = edit::telemetry::auto_zoom_segments(
344            &clicks,
345            30,
346            &edit::style::AutoZoomConfig::default(),
347        );
348        assert_eq!(zooms.len(), 1, "one click → one auto-zoom region");
349    }
350}