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}