@desklink/host 0.5.9 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -163,6 +163,50 @@ int desklink_agent_overlay_main(const char *display_name) {
163
163
  return 0;
164
164
  }
165
165
 
166
+ // The ring is 72 logical px across, a 6px band with a pulse wave that grows
167
+ // out from it every PULSE_SECONDS, so the cue reads at a glance. The label is
168
+ // the core "fixed" font drawn at 2x, because a core X server offers nothing larger.
169
+ #define POINT_RING 36
170
+ #define POINT_PULSE_SECONDS 1.2
171
+ #define POINT_BADGE_H 40
172
+ #define POINT_FRAME_SECONDS 0.033
173
+
174
+ static void point_shape(Display *d, Window window, Pixmap mask, GC shape, int x, int y,
175
+ int badge_x, int badge_y, int badge_w, double elapsed) {
176
+ double phase = fmod(elapsed, POINT_PULSE_SECONDS) / POINT_PULSE_SECONDS;
177
+ int wave = POINT_RING + (int)lround(18*phase), wave_width = 6 - (int)lround(4*phase);
178
+ XSetForeground(d, shape, 0); XFillRectangle(d, mask, shape, 0, 0, 0xffff, 0xffff);
179
+ XSetForeground(d, shape, 1);
180
+ XSetLineAttributes(d, shape, 6, LineSolid, CapRound, JoinRound);
181
+ XDrawArc(d, mask, shape, x-POINT_RING, y-POINT_RING, 2*POINT_RING, 2*POINT_RING, 0, 360*64);
182
+ XFillArc(d, mask, shape, x-6, y-6, 12, 12, 0, 360*64);
183
+ XSetLineAttributes(d, shape, wave_width, LineSolid, CapRound, JoinRound);
184
+ XDrawArc(d, mask, shape, x-wave, y-wave, 2*wave, 2*wave, 0, 360*64);
185
+ if (badge_w) XFillRectangle(d, mask, shape, badge_x, badge_y, badge_w, POINT_BADGE_H);
186
+ XShapeCombineMask(d, window, ShapeBounding, 0, 0, mask, ShapeSet);
187
+ }
188
+
189
+ // Text is rasterised once at 1x and drawn as 2x2 blocks.
190
+ static void point_text(Display *d, Window window, GC ink, XFontStruct *font, const char *label,
191
+ int len, int x, int y) {
192
+ int w = XTextWidth(font, label, len), h = font->ascent + font->descent;
193
+ Pixmap bits = XCreatePixmap(d, window, w, h, 1);
194
+ GC gc = XCreateGC(d, bits, 0, NULL);
195
+ XSetFont(d, gc, font->fid);
196
+ XSetForeground(d, gc, 0); XFillRectangle(d, bits, gc, 0, 0, w, h);
197
+ XSetForeground(d, gc, 1); XDrawString(d, bits, gc, 0, font->ascent, label, len);
198
+ XImage *image = XGetImage(d, bits, 0, 0, w, h, 1, ZPixmap);
199
+ XRectangle *blocks = malloc(sizeof(XRectangle) * (size_t)w * h);
200
+ int count = 0;
201
+ if (image && blocks)
202
+ for (int row = 0; row < h; row++) for (int col = 0; col < w; col++)
203
+ if (XGetPixel(image, col, row))
204
+ blocks[count++] = (XRectangle){(short)(x+2*col), (short)(y+2*row), 2, 2};
205
+ if (count) XFillRectangles(d, window, ink, blocks, count);
206
+ free(blocks); if (image) XDestroyImage(image);
207
+ XFreeGC(d, gc); XFreePixmap(d, bits);
208
+ }
209
+
166
210
  // Point cues are independent of the real cursor. A shaped override-redirect
167
211
  // window works without a compositor; an empty input shape passes every event
168
212
  // through and mapping it never asks for keyboard focus. It intentionally appears
@@ -185,12 +229,11 @@ int desklink_point_overlay_main(const char *display_name) {
185
229
  Pixmap mask = XCreatePixmap(d, root, width, height, 1);
186
230
  GC shape = XCreateGC(d, mask, 0, NULL), ink = XCreateGC(d, window, 0, NULL);
187
231
  XFontStruct *font = XLoadQueryFont(d, "fixed");
188
- if (font) XSetFont(d, ink, font->fid);
189
- XSetLineAttributes(d, shape, 4, LineSolid, CapRound, JoinRound);
190
232
  fcntl(STDIN_FILENO, F_SETFL, fcntl(STDIN_FILENO, F_GETFL) | O_NONBLOCK);
191
233
  puts("READY"); fflush(stdout);
192
234
  char pending[512]; size_t used = 0;
193
- double deadline = 0;
235
+ double deadline = 0, started = 0, drawn = 0;
236
+ int x = 0, y = 0, badge_x = 0, badge_y = 0, badge_w = 0;
194
237
  for (;;) {
195
238
  char bytes[512]; ssize_t n = read(STDIN_FILENO, bytes, sizeof(bytes));
196
239
  if (n == 0) break;
@@ -200,35 +243,41 @@ int desklink_point_overlay_main(const char *display_name) {
200
243
  double px, py; unsigned timeout; int offset = 0;
201
244
  if (sscanf(pending, "P %lf %lf %u %n", &px, &py, &timeout, &offset) != 3 || !offset) continue;
202
245
  const char *label = pending + offset;
203
- int x = (int)px, y = (int)py, len = (int)strlen(label);
204
- int badge_w = font ? XTextWidth(font, label, len) + 16 : len * 6 + 16;
246
+ int len = (int)strlen(label);
247
+ x = (int)px; y = (int)py;
248
+ int text_w = font ? 2*XTextWidth(font, label, len) : 12*len;
249
+ badge_w = len ? text_w + 24 : 0;
205
250
  if (badge_w > width) badge_w = width;
206
- int badge_x = x + 30, badge_y = y - 14;
251
+ badge_x = x + POINT_RING + 30; badge_y = y - POINT_BADGE_H/2;
207
252
  if (badge_x + badge_w > width) badge_x = width - badge_w;
208
253
  if (badge_y < 0) badge_y = 0;
209
- if (badge_y + 28 > height) badge_y = height - 28;
254
+ if (badge_y + POINT_BADGE_H > height) badge_y = height - POINT_BADGE_H;
210
255
  XUnmapWindow(d, window);
211
- XSetForeground(d, shape, 0); XFillRectangle(d, mask, shape, 0, 0, width, height);
212
- XSetForeground(d, shape, 1);
213
- XDrawArc(d, mask, shape, x-18, y-18, 36, 36, 0, 360*64);
214
- XFillArc(d, mask, shape, x-3, y-3, 6, 6, 0, 360*64);
215
- if (len) XFillRectangle(d, mask, shape, badge_x, badge_y, badge_w, 28);
216
- XShapeCombineMask(d, window, ShapeBounding, 0, 0, mask, ShapeSet);
256
+ started = now();
257
+ point_shape(d, window, mask, shape, x, y, badge_x, badge_y, badge_w, 0);
217
258
  XMapRaised(d, window);
218
259
  XClearWindow(d, window);
219
260
  if (len) {
220
261
  XSetForeground(d, ink, 0x123047);
221
- XFillRectangle(d, window, ink, badge_x, badge_y, badge_w, 28);
262
+ XFillRectangle(d, window, ink, badge_x, badge_y, badge_w, POINT_BADGE_H);
222
263
  XSetForeground(d, ink, 0xffffff);
223
- XDrawString(d, window, ink, badge_x+8, badge_y+18, label, len);
264
+ if (font) point_text(d, window, ink, font, label, len, badge_x+12,
265
+ badge_y + (POINT_BADGE_H - 2*(font->ascent+font->descent))/2);
224
266
  }
225
267
  XSync(d, False);
226
- deadline = now() + timeout/1000.0;
268
+ drawn = now();
269
+ deadline = drawn + timeout/1000.0;
270
+ }
271
+ double t = now();
272
+ if (deadline && t >= deadline) { XUnmapWindow(d, window); XFlush(d); deadline = 0; }
273
+ else if (deadline && t - drawn >= POINT_FRAME_SECONDS) {
274
+ point_shape(d, window, mask, shape, x, y, badge_x, badge_y, badge_w, t - started);
275
+ XFlush(d); drawn = t;
227
276
  }
228
- if (deadline && now() >= deadline) { XUnmapWindow(d, window); XFlush(d); deadline = 0; }
229
277
  usleep(5000);
230
278
  }
231
279
  XDestroyWindow(d, window); XFreeGC(d, shape); XFreeGC(d, ink);
232
280
  XFreePixmap(d, mask); if (font) XFreeFont(d, font); XCloseDisplay(d);
233
281
  return 0;
234
282
  }
283
+
@@ -299,3 +299,436 @@ impl InputDevices {
299
299
  }
300
300
  }
301
301
  }
302
+
303
+ /// Whether the engine may listen to the desktop's input. Listening needs Input
304
+ /// Monitoring, a separate grant from the Accessibility one that posting needs.
305
+ pub fn observe_probe() -> Result<(), InputUnavailable> {
306
+ if unsafe { crate::mac::CGPreflightListenEventAccess() } {
307
+ Ok(())
308
+ } else {
309
+ let app = crate::mac::tcc_responsible_app_name();
310
+ Err(InputUnavailable {
311
+ reason: format!("Input Monitoring permission has not been granted to {app}."),
312
+ remedy: format!("Allow {app} in System Settings › Privacy & Security › Input Monitoring, then reconnect."),
313
+ })
314
+ }
315
+ }
316
+
317
+ /// Shows the system's Input Monitoring prompt, once, and lists the app there.
318
+ pub fn request_observe_access() -> bool {
319
+ unsafe { crate::mac::CGRequestListenEventAccess() }
320
+ }
321
+
322
+ #[cfg(desklink_vpx)]
323
+ pub use observer::observe;
324
+
325
+ #[cfg(desklink_vpx)]
326
+ mod observer {
327
+ use super::*;
328
+ use crate::observe::{Observation, Sink, Tap, Unavailable};
329
+ use crate::protocol::{timestamp_us, ObservedInput, PointerPhase};
330
+ use std::sync::atomic::{AtomicBool, Ordering};
331
+ use std::sync::Arc;
332
+ use std::time::Instant;
333
+
334
+ type TapCallback = extern "C" fn(EventRef, u32, EventRef, *mut c_void) -> EventRef;
335
+
336
+ #[link(name = "ApplicationServices", kind = "framework")]
337
+ unsafe extern "C" {
338
+ fn CGEventTapCreate(tap: u32, place: u32, options: u32, events_of_interest: u64, callback: TapCallback, user_info: *mut c_void) -> CfTypeRef;
339
+ fn CGEventTapEnable(tap: CfTypeRef, enable: bool);
340
+ fn CGEventCreate(source: SourceRef) -> EventRef;
341
+ fn CGEventGetLocation(event: EventRef) -> Point;
342
+ fn CGEventGetFlags(event: EventRef) -> u64;
343
+ fn CGEventGetIntegerValueField(event: EventRef, field: u32) -> i64;
344
+ fn CGEventKeyboardGetUnicodeString(event: EventRef, max_length: usize, actual_length: *mut usize, buffer: *mut u16);
345
+ }
346
+
347
+ #[link(name = "CoreFoundation", kind = "framework")]
348
+ unsafe extern "C" {
349
+ fn CFMachPortCreateRunLoopSource(allocator: CfTypeRef, port: CfTypeRef, order: isize) -> CfTypeRef;
350
+ fn CFMachPortInvalidate(port: CfTypeRef);
351
+ fn CFRunLoopGetCurrent() -> CfTypeRef;
352
+ fn CFRunLoopAddSource(run_loop: CfTypeRef, source: CfTypeRef, mode: CfTypeRef);
353
+ fn CFRunLoopRunInMode(mode: CfTypeRef, seconds: f64, return_after_source: u8) -> i32;
354
+ static kCFRunLoopCommonModes: CfTypeRef;
355
+ static kCFRunLoopDefaultMode: CfTypeRef;
356
+ }
357
+
358
+ // CGEventType values this tap listens for.
359
+ const LEFT_DOWN: u32 = 1; const LEFT_UP: u32 = 2; const RIGHT_DOWN: u32 = 3; const RIGHT_UP: u32 = 4;
360
+ const MOVED: u32 = 5; const LEFT_DRAGGED: u32 = 6; const RIGHT_DRAGGED: u32 = 7; const KEY_DOWN: u32 = 10;
361
+ const KEY_UP: u32 = 11; const FLAGS_CHANGED: u32 = 12; const OTHER_DOWN: u32 = 25; const OTHER_UP: u32 = 26;
362
+ const OTHER_DRAGGED: u32 = 27;
363
+ // The system switches a tap off when its callback is slow or on its own
364
+ // say-so; these arrive in its place.
365
+ const DISABLED_BY_TIMEOUT: u32 = 0xFFFF_FFFE;
366
+ const DISABLED_BY_USER_INPUT: u32 = 0xFFFF_FFFF;
367
+
368
+ const BUTTON_NUMBER: u32 = 3;
369
+ const KEYCODE: u32 = 9;
370
+ const AUTOREPEAT: u32 = 8;
371
+
372
+ /// Where the selected display is on the desktop, so a point the system
373
+ /// reports (global, in points) becomes the source's own pixel.
374
+ #[derive(Debug, Clone, Copy, PartialEq)]
375
+ pub struct Geometry {
376
+ origin: (f64, f64),
377
+ points_per_pixel: (f64, f64),
378
+ pixels: (i64, i64),
379
+ }
380
+
381
+ impl Geometry {
382
+ pub fn new(origin: (f64, f64), points: (f64, f64), pixels: (i64, i64)) -> Self {
383
+ Self {
384
+ origin,
385
+ points_per_pixel: (points.0 / pixels.0 as f64, points.1 / pixels.1 as f64),
386
+ pixels,
387
+ }
388
+ }
389
+
390
+ /// `None` when the point is on another display.
391
+ pub fn to_source(&self, x: f64, y: f64) -> Option<(i64, i64)> {
392
+ let px = ((x - self.origin.0) / self.points_per_pixel.0).floor() as i64;
393
+ let py = ((y - self.origin.1) / self.points_per_pixel.1).floor() as i64;
394
+ ((0..self.pixels.0).contains(&px) && (0..self.pixels.1).contains(&py))
395
+ .then_some((px, py))
396
+ }
397
+ }
398
+
399
+ /// The control channel's pointer button numbers: 1 left, 2 middle, 3 right,
400
+ /// and 4 and up for the extra buttons.
401
+ pub fn observed_button(event_type: u32, button_number: i64) -> Option<(PointerPhase, i64)> {
402
+ let extra = |down| match button_number {
403
+ 2 => Some((down, 2)),
404
+ n if n > 2 => Some((down, n + 1)),
405
+ _ => None,
406
+ };
407
+ match event_type {
408
+ LEFT_DOWN => Some((PointerPhase::Down, 1)),
409
+ LEFT_UP => Some((PointerPhase::Up, 1)),
410
+ RIGHT_DOWN => Some((PointerPhase::Down, 3)),
411
+ RIGHT_UP => Some((PointerPhase::Up, 3)),
412
+ OTHER_DOWN => extra(PointerPhase::Down),
413
+ OTHER_UP => extra(PointerPhase::Up),
414
+ _ => None,
415
+ }
416
+ }
417
+
418
+ struct Observer {
419
+ geometry: Geometry,
420
+ sink: Sink,
421
+ cursor: crate::cursor::Reporter,
422
+ last_inside: (i64, i64),
423
+ port: CfTypeRef,
424
+ }
425
+
426
+ impl Observer {
427
+ fn handle(&mut self, event_type: u32, event: EventRef) {
428
+ match event_type {
429
+ DISABLED_BY_TIMEOUT | DISABLED_BY_USER_INPUT => {
430
+ log::warn!("the input event tap was switched off by the system; re-enabling it");
431
+ unsafe { CGEventTapEnable(self.port, true) };
432
+ }
433
+ MOVED | LEFT_DRAGGED | RIGHT_DRAGGED | OTHER_DRAGGED => {
434
+ let at = unsafe { CGEventGetLocation(event) };
435
+ self.report_pointer(at);
436
+ }
437
+ LEFT_DOWN | LEFT_UP | RIGHT_DOWN | RIGHT_UP | OTHER_DOWN | OTHER_UP => {
438
+ let number = unsafe { CGEventGetIntegerValueField(event, BUTTON_NUMBER) };
439
+ let Some((phase, button)) = observed_button(event_type, number) else {
440
+ return;
441
+ };
442
+ let at = unsafe { CGEventGetLocation(event) };
443
+ // A click on another display is not on this source.
444
+ let Some((x, y)) = self.geometry.to_source(at.x, at.y) else {
445
+ return;
446
+ };
447
+ self.last_inside = (x, y);
448
+ self.send(ObservedInput::Pointer { phase, x, y, button });
449
+ }
450
+ KEY_DOWN | KEY_UP | FLAGS_CHANGED => {
451
+ let code = unsafe { CGEventGetIntegerValueField(event, KEYCODE) } as i16;
452
+ let flags = unsafe { CGEventGetFlags(event) };
453
+ let repeat = unsafe { CGEventGetIntegerValueField(event, AUTOREPEAT) } != 0;
454
+ let text = if event_type == FLAGS_CHANGED {
455
+ String::new()
456
+ } else {
457
+ let mut buffer = [0u16; 8];
458
+ let mut length = 0usize;
459
+ unsafe {
460
+ CGEventKeyboardGetUnicodeString(
461
+ event,
462
+ buffer.len(),
463
+ &mut length,
464
+ buffer.as_mut_ptr(),
465
+ )
466
+ };
467
+ String::from_utf16_lossy(&buffer[..length.min(buffer.len())])
468
+ };
469
+ if let Some(input) =
470
+ crate::keymap::observed_key(event_type, code, flags, repeat, &text)
471
+ {
472
+ self.send(input);
473
+ }
474
+ }
475
+ _ => {}
476
+ }
477
+ }
478
+
479
+ /// Reports where the pointer is now, whether or not it moves.
480
+ fn report_pointer(&mut self, at: Point) {
481
+ match self.geometry.to_source(at.x, at.y) {
482
+ Some(inside) => {
483
+ self.last_inside = inside;
484
+ self.cursor.update(inside.0 as i32, inside.1 as i32, true);
485
+ }
486
+ None => self.cursor.update(
487
+ self.last_inside.0 as i32,
488
+ self.last_inside.1 as i32,
489
+ false,
490
+ ),
491
+ }
492
+ }
493
+
494
+ fn send(&self, input: ObservedInput) {
495
+ (self.sink)(Observation::Input {
496
+ timestamp_us: timestamp_us(Instant::now()),
497
+ input,
498
+ });
499
+ }
500
+ }
501
+
502
+ extern "C" fn tap_callback(
503
+ _proxy: EventRef,
504
+ event_type: u32,
505
+ event: EventRef,
506
+ user_info: *mut c_void,
507
+ ) -> EventRef {
508
+ // The run loop of the tap's own thread is the only caller.
509
+ let observer = unsafe { &mut *(user_info as *mut Observer) };
510
+ observer.handle(event_type, event);
511
+ event
512
+ }
513
+
514
+ /// The observer thread and the flag that ends it.
515
+ struct Running {
516
+ stop: Arc<AtomicBool>,
517
+ thread: Option<std::thread::JoinHandle<()>>,
518
+ }
519
+
520
+ impl Drop for Running {
521
+ fn drop(&mut self) {
522
+ self.stop.store(true, Ordering::Release);
523
+ if let Some(thread) = self.thread.take() {
524
+ let _ = thread.join();
525
+ }
526
+ }
527
+ }
528
+
529
+ fn unavailable(code: &'static str, reason: String, remedy: String) -> Unavailable {
530
+ Unavailable {
531
+ code,
532
+ reason,
533
+ remedy,
534
+ }
535
+ }
536
+
537
+ /// Listens to the pointer and keyboard of the display `display_id` with a
538
+ /// listen-only event tap: it sees events, it cannot change or block them.
539
+ pub fn observe(
540
+ display_id: u32,
541
+ pixels: (usize, usize),
542
+ sink: Sink,
543
+ ) -> Result<Tap, Unavailable> {
544
+ let app = crate::mac::tcc_responsible_app_name();
545
+ // Preflight never prompts; the owner grants with `setup-input` or by
546
+ // adding the app in System Settings.
547
+ if let Err(missing) = observe_probe() {
548
+ return Err(unavailable(
549
+ "missing-input-monitoring",
550
+ missing.reason,
551
+ missing.remedy,
552
+ ));
553
+ }
554
+ let (origin_x, origin_y, width, height) = crate::mac::display_geometry(display_id)
555
+ .ok_or_else(|| {
556
+ unavailable(
557
+ "display-unavailable",
558
+ String::from("the selected display is no longer active"),
559
+ String::from("select an active display and reconnect"),
560
+ )
561
+ })?;
562
+ let geometry = Geometry::new(
563
+ (origin_x, origin_y),
564
+ (width, height),
565
+ (pixels.0 as i64, pixels.1 as i64),
566
+ );
567
+ let (started_tx, started_rx) = std::sync::mpsc::channel();
568
+ let stop = Arc::new(AtomicBool::new(false));
569
+ let stopped = stop.clone();
570
+ let thread = std::thread::Builder::new()
571
+ .name(String::from("input-observer"))
572
+ .spawn(move || {
573
+ let cursor_sink = sink.clone();
574
+ let observer = Box::into_raw(Box::new(Observer {
575
+ geometry,
576
+ sink,
577
+ cursor: crate::cursor::Reporter::new(Box::new(move |position| {
578
+ cursor_sink(Observation::Cursor(position))
579
+ })),
580
+ last_inside: (0, 0),
581
+ port: std::ptr::null(),
582
+ }));
583
+ let mask: u64 = [
584
+ LEFT_DOWN,
585
+ LEFT_UP,
586
+ RIGHT_DOWN,
587
+ RIGHT_UP,
588
+ MOVED,
589
+ LEFT_DRAGGED,
590
+ RIGHT_DRAGGED,
591
+ KEY_DOWN,
592
+ KEY_UP,
593
+ FLAGS_CHANGED,
594
+ OTHER_DOWN,
595
+ OTHER_UP,
596
+ OTHER_DRAGGED,
597
+ ]
598
+ .iter()
599
+ .fold(0u64, |mask, event| mask | (1u64 << *event));
600
+ // kCGSessionEventTap, kCGHeadInsertEventTap, kCGEventTapOptionListenOnly.
601
+ let port =
602
+ unsafe { CGEventTapCreate(1, 0, 1, mask, tap_callback, observer.cast()) };
603
+ if port.is_null() {
604
+ drop(unsafe { Box::from_raw(observer) });
605
+ let _ = started_tx.send(Err(()));
606
+ return;
607
+ }
608
+ unsafe { (*observer).port = port };
609
+ let source = unsafe { CFMachPortCreateRunLoopSource(std::ptr::null(), port, 0) };
610
+ let run_loop = unsafe { CFRunLoopGetCurrent() };
611
+ unsafe {
612
+ CFRunLoopAddSource(run_loop, source, kCFRunLoopCommonModes);
613
+ CGEventTapEnable(port, true);
614
+ // Where the pointer already is, so a consumer does not wait for it to move.
615
+ let here = CGEventCreate(std::ptr::null());
616
+ if !here.is_null() {
617
+ (*observer).report_pointer(CGEventGetLocation(here));
618
+ CFRelease(here);
619
+ }
620
+ }
621
+ let _ = started_tx.send(Ok(()));
622
+ // Polled rather than stopped from outside: stopping a run loop
623
+ // that has not started yet is lost, and Drop would wait forever.
624
+ while !stopped.load(Ordering::Acquire) {
625
+ unsafe { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, 0) };
626
+ }
627
+ unsafe {
628
+ CGEventTapEnable(port, false);
629
+ CFMachPortInvalidate(port);
630
+ CFRelease(source);
631
+ CFRelease(port);
632
+ drop(Box::from_raw(observer));
633
+ }
634
+ })
635
+ .map_err(|error| {
636
+ unavailable(
637
+ "event-tap-failed",
638
+ format!("could not start the input observer thread: {error}"),
639
+ String::from("retry; if it persists, restart the engine"),
640
+ )
641
+ })?;
642
+ match started_rx.recv() {
643
+ Ok(Ok(())) => Ok(Tap {
644
+ mechanism: "cgeventtap",
645
+ guard: Box::new(Running {
646
+ stop,
647
+ thread: Some(thread),
648
+ }),
649
+ }),
650
+ _ => {
651
+ let _ = thread.join();
652
+ Err(unavailable(
653
+ "event-tap-failed",
654
+ format!("macOS refused to create the input event tap for {app}."),
655
+ format!("Remove {app} from System Settings › Privacy & Security › Input Monitoring, add it again, then reconnect."),
656
+ ))
657
+ }
658
+ }
659
+ }
660
+
661
+ #[cfg(test)]
662
+ mod tests {
663
+ use super::*;
664
+
665
+ // A 3420x2224 pixel panel shown as 1710x1112 points, with a second
666
+ // display to its right.
667
+ fn geometry() -> Geometry {
668
+ Geometry::new((0.0, 0.0), (1710.0, 1112.0), (3420, 2224))
669
+ }
670
+
671
+ #[test]
672
+ fn a_point_becomes_the_source_pixel_under_it() {
673
+ assert_eq!(geometry().to_source(100.5, 50.0), Some((201, 100)));
674
+ assert_eq!(geometry().to_source(0.0, 0.0), Some((0, 0)));
675
+ assert_eq!(geometry().to_source(1709.9, 1111.9), Some((3419, 2223)));
676
+ }
677
+
678
+ #[test]
679
+ fn a_point_on_another_display_is_not_on_this_source() {
680
+ assert_eq!(geometry().to_source(1710.0, 10.0), None);
681
+ assert_eq!(geometry().to_source(-1.0, 10.0), None);
682
+ assert_eq!(geometry().to_source(10.0, 1112.0), None);
683
+ }
684
+
685
+ #[test]
686
+ fn a_display_left_of_the_main_one_maps_from_its_own_origin() {
687
+ let left = Geometry::new((-1920.0, 0.0), (1920.0, 1080.0), (1920, 1080));
688
+ assert_eq!(left.to_source(-1920.0, 0.0), Some((0, 0)));
689
+ assert_eq!(left.to_source(-1.0, 1.0), Some((1919, 1)));
690
+ assert_eq!(left.to_source(0.0, 1.0), None);
691
+ }
692
+
693
+ #[test]
694
+ fn buttons_use_the_control_channels_numbers() {
695
+ assert_eq!(observed_button(LEFT_DOWN, 0), Some((PointerPhase::Down, 1)));
696
+ assert_eq!(observed_button(LEFT_UP, 0), Some((PointerPhase::Up, 1)));
697
+ assert_eq!(observed_button(RIGHT_DOWN, 1), Some((PointerPhase::Down, 3)));
698
+ assert_eq!(observed_button(RIGHT_UP, 1), Some((PointerPhase::Up, 3)));
699
+ assert_eq!(observed_button(OTHER_DOWN, 2), Some((PointerPhase::Down, 2)));
700
+ assert_eq!(observed_button(OTHER_UP, 3), Some((PointerPhase::Up, 4)));
701
+ }
702
+
703
+ #[test]
704
+ fn a_motion_or_a_key_is_not_a_button() {
705
+ assert_eq!(observed_button(MOVED, 0), None);
706
+ assert_eq!(observed_button(KEY_DOWN, 0), None);
707
+ assert_eq!(observed_button(OTHER_DOWN, 1), None);
708
+ }
709
+
710
+ #[test]
711
+ fn without_input_monitoring_the_tap_is_typed_unavailable_and_silent() {
712
+ // The CI and lab Macs this runs on have not granted Input Monitoring
713
+ // to the test binary; where one has, there is nothing to refuse.
714
+ if observe_probe().is_ok() {
715
+ return;
716
+ }
717
+ let delivered = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
718
+ let counted = delivered.clone();
719
+ let refused = observe(
720
+ 1,
721
+ (1920, 1080),
722
+ std::sync::Arc::new(move |_| {
723
+ counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
724
+ }),
725
+ );
726
+ let Err(refused) = refused else {
727
+ panic!("a tap was created without the grant");
728
+ };
729
+ assert_eq!(refused.code, "missing-input-monitoring");
730
+ assert!(refused.remedy.contains("Input Monitoring"));
731
+ assert_eq!(delivered.load(std::sync::atomic::Ordering::SeqCst), 0);
732
+ }
733
+ }
734
+ }