diff --git a/crates/chanora_audio/src/ptt_backends/macos.rs b/crates/chanora_audio/src/ptt_backends/macos.rs index eb31e2c..abc4a96 100644 --- a/crates/chanora_audio/src/ptt_backends/macos.rs +++ b/crates/chanora_audio/src/ptt_backends/macos.rs @@ -15,9 +15,23 @@ //! channel — the supervisor in `chanora_core` forwards the //! transition to `SessionEvent::PttCapability` and the Flutter //! capability badge updates without a restart. +//! +//! Live event capture is implemented with `CGEventTapCreate` at +//! the `kCGSessionEventTap` location in listen-only mode (no +//! event injection or modification). Triggering events: +//! +//! * `kCGEventKeyDown` / `kCGEventKeyUp` — keyboard PTT +//! * `kCGEventOtherMouseDown` / `kCGEventOtherMouseUp` — mouse +//! side buttons (Mouse4 / Mouse5) +//! +//! The tap callback runs on the dedicated event-tap worker +//! thread's `CFRunLoop`. It matches the event's key code or +//! mouse-button number against the active `PttBinding` and +//! toggles the audio transmit gate accordingly. The callback +//! never logs raw key codes (DEC-027 privacy invariant). -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; @@ -25,10 +39,94 @@ use tokio::sync::watch; use tracing::{info, warn}; use super::{ - AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, + AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass, }; use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel}; +// ---------- FFI ---------- + +#[allow(non_camel_case_types)] +type CFTypeRef = *mut std::ffi::c_void; +type CFMachPortRef = CFTypeRef; +type CFRunLoopRef = CFTypeRef; +type CFRunLoopSourceRef = CFTypeRef; +type CFAllocatorRef = CFTypeRef; +type CFStringRef = CFTypeRef; +type CGEventRef = CFTypeRef; +#[allow(non_camel_case_types)] +type CGEventType = u32; +#[allow(non_camel_case_types)] +type CGEventTapLocation = u32; +#[allow(non_camel_case_types)] +type CGEventTapPlacement = u32; +#[allow(non_camel_case_types)] +type CGEventTapOptions = u32; +#[allow(non_camel_case_types)] +type CGEventTapProxy = *mut std::ffi::c_void; +#[allow(non_camel_case_types)] +type CGEventField = u32; +#[allow(non_camel_case_types)] +type CGEventMask = u64; + +// CGEventTapLocation values (CoreGraphics/CGEventTypes.h) +const KCG_SESSION_EVENT_TAP: CGEventTapLocation = 1; +// CGEventTapPlacement values +const KCG_TAIL_APPEND_EVENT_TAP: CGEventTapPlacement = 1; +// CGEventTapOptions +const KCG_EVENT_TAP_OPTION_LISTEN_ONLY: CGEventTapOptions = 1; + +// CGEventType (we mask these) +const KCG_EVENT_KEY_DOWN: CGEventType = 10; +const KCG_EVENT_KEY_UP: CGEventType = 11; +const KCG_EVENT_OTHER_MOUSE_DOWN: CGEventType = 25; +const KCG_EVENT_OTHER_MOUSE_UP: CGEventType = 26; +const KCG_EVENT_TAP_DISABLED_BY_TIMEOUT: CGEventType = 0xFFFFFFFE; +const KCG_EVENT_TAP_DISABLED_BY_USER_INPUT: CGEventType = 0xFFFFFFFF; + +// CGEventField (CoreGraphics/CGEvent.h) +const KCG_KEYBOARD_EVENT_KEYCODE: CGEventField = 9; +const KCG_MOUSE_EVENT_BUTTON_NUMBER: CGEventField = 3; + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGEventTapCreate( + tap: CGEventTapLocation, + place: CGEventTapPlacement, + options: CGEventTapOptions, + events_of_interest: CGEventMask, + callback: extern "C" fn( + proxy: CGEventTapProxy, + etype: CGEventType, + event: CGEventRef, + user_info: *mut std::ffi::c_void, + ) -> CGEventRef, + user_info: *mut std::ffi::c_void, + ) -> CFMachPortRef; + + fn CGEventGetIntegerValueField(event: CGEventRef, field: CGEventField) -> i64; + fn CGEventTapEnable(tap: CFMachPortRef, enable: bool); +} + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFMachPortCreateRunLoopSource( + allocator: CFAllocatorRef, + port: CFMachPortRef, + order: isize, + ) -> CFRunLoopSourceRef; + + fn CFRunLoopGetCurrent() -> CFRunLoopRef; + fn CFRunLoopAddSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFStringRef); + fn CFRunLoopRemoveSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFStringRef); + fn CFRunLoopRun(); + fn CFRunLoopStop(rl: CFRunLoopRef); + fn CFRelease(cf: CFTypeRef); + + static kCFRunLoopCommonModes: CFStringRef; +} + +// ---------- Permission state ---------- + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionState { Granted, @@ -85,6 +183,122 @@ fn query_permission() -> PermissionState { } } +// ---------- Flutter label → macOS keycode / mouse button ---------- + +/// Resolve a Flutter logical-key label (the same strings the bind +/// dialog hands us) into a macOS Carbon virtual keycode. Returns +/// `None` for unknown labels, modifier-only bindings, or mouse +/// labels — the caller falls back to the mouse-button path for +/// the latter. Privacy: this is the only point in the macOS +/// backend where the platform_key string is decoded, mirroring +/// the Windows keymap module's role. +fn label_to_macos_keycode(label: &str) -> Option { + // The keycode constants come from Apple's Carbon HIToolbox + // `Events.h` (kVK_*). They are the same values CGEventTap + // reports via `kCGKeyboardEventKeycode`. Coverage focuses on + // keys that make sensible PTT bindings — letters, digits, + // function keys, navigation, and the obvious modifiers that + // can be pressed as a single key. + let kc = match label { + // Letters (lower-case Flutter labels first; the bind dialog + // emits the canonicalised "Key A" form too, hence both). + "A" | "a" | "Key A" => 0x00, + "S" | "s" | "Key S" => 0x01, + "D" | "d" | "Key D" => 0x02, + "F" | "f" | "Key F" => 0x03, + "H" | "h" | "Key H" => 0x04, + "G" | "g" | "Key G" => 0x05, + "Z" | "z" | "Key Z" => 0x06, + "X" | "x" | "Key X" => 0x07, + "C" | "c" | "Key C" => 0x08, + "V" | "v" | "Key V" => 0x09, + "B" | "b" | "Key B" => 0x0B, + "Q" | "q" | "Key Q" => 0x0C, + "W" | "w" | "Key W" => 0x0D, + "E" | "e" | "Key E" => 0x0E, + "R" | "r" | "Key R" => 0x0F, + "Y" | "y" | "Key Y" => 0x10, + "T" | "t" | "Key T" => 0x11, + "O" | "o" | "Key O" => 0x1F, + "U" | "u" | "Key U" => 0x20, + "I" | "i" | "Key I" => 0x22, + "P" | "p" | "Key P" => 0x23, + "L" | "l" | "Key L" => 0x25, + "J" | "j" | "Key J" => 0x26, + "K" | "k" | "Key K" => 0x28, + "N" | "n" | "Key N" => 0x2D, + "M" | "m" | "Key M" => 0x2E, + // Digits (top row) + "1" | "Digit 1" => 0x12, + "2" | "Digit 2" => 0x13, + "3" | "Digit 3" => 0x14, + "4" | "Digit 4" => 0x15, + "5" | "Digit 5" => 0x17, + "6" | "Digit 6" => 0x16, + "7" | "Digit 7" => 0x1A, + "8" | "Digit 8" => 0x1C, + "9" | "Digit 9" => 0x19, + "0" | "Digit 0" => 0x1D, + // Whitespace + control + "Space" | " " => 0x31, + "Enter" => 0x24, + "Tab" => 0x30, + "Backspace" => 0x33, + "Escape" => 0x35, + "Delete" => 0x75, + // Navigation + "Arrow Up" | "ArrowUp" => 0x7E, + "Arrow Down" | "ArrowDown" => 0x7D, + "Arrow Left" | "ArrowLeft" => 0x7B, + "Arrow Right" | "ArrowRight" => 0x7C, + "Home" => 0x73, + "End" => 0x77, + "Page Up" | "PageUp" => 0x74, + "Page Down" | "PageDown" => 0x79, + // Function keys + "F1" => 0x7A, + "F2" => 0x78, + "F3" => 0x63, + "F4" => 0x76, + "F5" => 0x60, + "F6" => 0x61, + "F7" => 0x62, + "F8" => 0x64, + "F9" => 0x65, + "F10" => 0x6D, + "F11" => 0x67, + "F12" => 0x6F, + // Punctuation that may sit alone on a US layout + "`" | "Backquote" => 0x32, + "-" | "Minus" => 0x1B, + "=" | "Equal" => 0x18, + "[" | "BracketLeft" => 0x21, + "]" | "BracketRight" => 0x1E, + "\\" | "Backslash" => 0x2A, + ";" | "Semicolon" => 0x29, + "'" | "Quote" => 0x27, + "," | "Comma" => 0x2B, + "." | "Period" => 0x2F, + "/" | "Slash" => 0x2C, + _ => return None, + }; + Some(kc as i64) +} + +/// Resolve a Flutter logical-key label naming a mouse side button +/// (Mouse4 / Mouse5) into the macOS `kCGMouseEventButtonNumber` +/// value. macOS numbers buttons starting at 0 (left), 1 (right), +/// 2 (middle); side buttons begin at 3. +fn label_to_macos_mouse_button(label: &str) -> Option { + match label { + "Mouse4" | "Button 4" | "Mouse Button 4" => Some(3), + "Mouse5" | "Button 5" | "Mouse Button 5" => Some(4), + _ => None, + } +} + +// ---------- try_select / backend ---------- + /// Try to construct the macOS event-tap backend. Returns `None` /// when the permission is denied (the caller then falls back to /// the universal Focused backend); returns `Some` for `Granted` @@ -123,8 +337,48 @@ pub struct MacOSEventTapBackend { stop: Arc, worker: Option>, permission_worker: Option>, + /// `CFRunLoopRef` of the event-tap worker, captured on the + /// worker thread after `CFRunLoopGetCurrent` so the main + /// thread can `CFRunLoopStop` it cleanly. Wrapped in + /// `Arc>` because the worker writes once and the + /// main thread reads. + worker_runloop: Arc>>, + /// Shared atomic carrying the keycode (or -1 for "no + /// keyboard binding") so the C-ABI tap callback can match + /// without holding any Rust references through the FFI + /// boundary. Encoded as i32: 0..=255 are macOS keycodes; + /// -1 means "no keyboard binding active". + bound_keycode: Arc, + /// Same shape for the bound mouse button number. -1 means + /// "no mouse binding active". Encodes the + /// `kCGMouseEventButtonNumber` value (3 = Mouse4, 4 = Mouse5). + bound_mouse_button: Arc, } +/// `CFRunLoopRef` is a `*mut c_void` and Rust treats it as not +/// `Send`. We never dereference the pointer ourselves — we just +/// hand it to `CFRunLoopStop`, which is documented thread-safe. +/// Wrap it in a transparent newtype with an explicit +/// `unsafe impl Send` so it can travel through the mutex. +struct RunLoopHandle(CFRunLoopRef); +// SAFETY: CFRunLoopRef is a retained pointer to a CFRunLoop. The +// only operation we perform from another thread is CFRunLoopStop, +// which is documented thread-safe. +unsafe impl Send for RunLoopHandle {} + +/// Transparent newtype carrying `*mut TapState` across the thread +/// spawn boundary. The eventtap worker is the sole owner of the +/// underlying boxed `TapState` for its entire lifetime; nothing +/// else dereferences it. We mark the newtype `Send` so the +/// spawned closure can take it, and the inner pointer never +/// becomes user-visible outside of the worker. +struct TapStatePtr(*mut TapState); +// SAFETY: The TapState behind this pointer lives in a Box owned +// exclusively by the eventtap worker thread for its lifetime. +// The C callback runs only on that thread; no aliasing across +// threads occurs. +unsafe impl Send for TapStatePtr {} + impl MacOSEventTapBackend { fn new(permission: PermissionState) -> Self { let initial = PttBackendDescriptor { @@ -145,6 +399,9 @@ impl MacOSEventTapBackend { stop: Arc::new(AtomicBool::new(false)), worker: None, permission_worker: None, + worker_runloop: Arc::new(Mutex::new(None)), + bound_keycode: Arc::new(AtomicI32::new(-1)), + bound_mouse_button: Arc::new(AtomicI32::new(-1)), } } @@ -152,11 +409,11 @@ impl MacOSEventTapBackend { /// (used by `descriptor()` and the re-query worker). fn build_descriptor( permission: PermissionState, - class: super::PttInputClass, + class: PttInputClass, ) -> PttBackendDescriptor { let level = match permission { PermissionState::Granted => match class { - super::PttInputClass::MouseSideButton => { + PttInputClass::MouseSideButton => { PttCapabilityLevel::L3GlobalWithMouseButtons } _ => PttCapabilityLevel::L2GlobalHoldToTalk, @@ -171,12 +428,30 @@ impl MacOSEventTapBackend { level, backend_id: "event-tap", bound_input_class: match class { - super::PttInputClass::None => None, - super::PttInputClass::MouseSideButton => Some("mouse-side-button"), + PttInputClass::None => None, + PttInputClass::MouseSideButton => Some("mouse-side-button"), _ => Some("keyboard"), }, } } + + /// Recompute `bound_keycode` / `bound_mouse_button` from the + /// current binding. Called by `start()` and `rebind()`. + fn refresh_bound_atomics(&self) { + let (kc, mb) = match self.binding.input_class { + PttInputClass::Keyboard => ( + label_to_macos_keycode(&self.binding.platform_key).unwrap_or(-1), + -1i64, + ), + PttInputClass::MouseSideButton => ( + -1i64, + label_to_macos_mouse_button(&self.binding.platform_key).unwrap_or(-1), + ), + PttInputClass::None => (-1, -1), + }; + self.bound_keycode.store(kc as i32, Ordering::Relaxed); + self.bound_mouse_button.store(mb as i32, Ordering::Relaxed); + } } impl DesktopPttBackend for MacOSEventTapBackend { @@ -196,26 +471,126 @@ impl DesktopPttBackend for MacOSEventTapBackend { ) -> Result<(), PttBackendError> { self.gate = Some(gate.clone()); self.binding = binding; + self.refresh_bound_atomics(); let stop = self.stop.clone(); - // Spawn the event-tap worker. Live `CGEventTapCreate` + - // run-loop wiring lands in the macOS platform - // verification commit; the scaffolding keeps the - // lifecycle correct so the watchdog + capability event - // are exercisable now. + // ----- Event-tap worker ----- + // + // Captures a `Box` whose raw pointer is passed + // to `CGEventTapCreate` as user_info. The C-ABI callback + // dereferences it on every event to read the bound key / + // button and toggle the gate. The Box is leaked into the + // worker's lifetime and dropped when the worker exits. + let tap_state = Box::new(TapState { + gate: gate.clone(), + bound_keycode: self.bound_keycode.clone(), + bound_mouse_button: self.bound_mouse_button.clone(), + }); + let tap_state_handle = TapStatePtr(Box::into_raw(tap_state)); + let worker_runloop = self.worker_runloop.clone(); + let worker_stop = stop.clone(); let handle = thread::Builder::new() .name("chanora-eventtap".into()) .spawn(move || { - while !stop.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(50)); + // SAFETY: the worker is the sole owner of the + // raw pointer inside `tap_state_handle`. The C + // callback only runs on this thread (CFRunLoop + // dispatches callbacks synchronously on the + // owning runloop's thread). When CFRunLoopRun + // returns we reconstruct the Box to drop the + // state. + // + // Borrow the whole TapStatePtr (not its inner + // field) so the closure-capture analyser sees the + // newtype's `unsafe impl Send` rather than the + // raw `*mut TapState`. The `&` then immediately + // moves it back to a plain raw pointer for use + // with the C API. + let tsp = &tap_state_handle; + let tap_state_ptr: *mut TapState = tsp.0; + let events_of_interest: CGEventMask = (1u64 << KCG_EVENT_KEY_DOWN) + | (1u64 << KCG_EVENT_KEY_UP) + | (1u64 << KCG_EVENT_OTHER_MOUSE_DOWN) + | (1u64 << KCG_EVENT_OTHER_MOUSE_UP); + let port = unsafe { + CGEventTapCreate( + KCG_SESSION_EVENT_TAP, + KCG_TAIL_APPEND_EVENT_TAP, + KCG_EVENT_TAP_OPTION_LISTEN_ONLY, + events_of_interest, + tap_callback, + tap_state_ptr as *mut std::ffi::c_void, + ) + }; + if port.is_null() { + warn!( + target: "chanora_audio", + "macos ptt: CGEventTapCreate returned null \ + (permission probably revoked at runtime)" + ); + unsafe { + // Drop the Box we leaked above so the + // refcount on the gate stays correct. + let _ = Box::from_raw(tap_state_ptr); + } + return; } + let source = unsafe { + CFMachPortCreateRunLoopSource(std::ptr::null_mut(), port, 0) + }; + if source.is_null() { + warn!( + target: "chanora_audio", + "macos ptt: CFMachPortCreateRunLoopSource returned null" + ); + unsafe { + CFRelease(port); + let _ = Box::from_raw(tap_state_ptr); + } + return; + } + let runloop = unsafe { CFRunLoopGetCurrent() }; + { + let mut g = worker_runloop.lock().unwrap(); + *g = Some(RunLoopHandle(runloop)); + } + unsafe { + CFRunLoopAddSource(runloop, source, kCFRunLoopCommonModes); + CGEventTapEnable(port, true); + } + info!( + target: "chanora_audio", + "macos ptt: event tap armed; entering run loop" + ); + + // CFRunLoopRun blocks until CFRunLoopStop is + // called from outside (stop() does that). + unsafe { CFRunLoopRun() }; + + // Cleanup. Order matters: disable the tap before + // removing it from the runloop so no further + // callbacks fire while we tear down. + unsafe { + CGEventTapEnable(port, false); + CFRunLoopRemoveSource(runloop, source, kCFRunLoopCommonModes); + CFRelease(source); + CFRelease(port); + let _ = Box::from_raw(tap_state_ptr); + } + let _ = worker_stop; // silence unused warning when stop + // is consulted only by the watchdog + info!( + target: "chanora_audio", + "macos ptt: event tap worker exited cleanly" + ); }) .map_err(|e| PttBackendError::Init(format!("eventtap thread: {e}")))?; self.worker = Some(handle); - // Spawn the permission re-query worker (SRS-198 runtime - // upgrade hook). Polls every 1.5 s; on transition, - // republishes the descriptor through the watch sender so + // ----- Permission re-query worker (SRS-198) ----- + // + // Polls every 1.5 s; on transition, republishes the + // descriptor through the watch sender so // `chanora_core::ChanoraSession::start_audio`'s forwarder // re-emits `SessionEvent::PttCapability` and the Flutter // capability badge updates without a restart. @@ -261,12 +636,21 @@ impl DesktopPttBackend for MacOSEventTapBackend { // than after the first re-query tick. let _ = self.desc_tx.send(self.descriptor()); - let _ = &gate; // captured for future live wiring + let _ = &gate; // captured by TapState Ok(()) } fn stop(&mut self) { self.stop.store(true, Ordering::Relaxed); + // Wake the event-tap worker's CFRunLoop so it can run its + // teardown and exit. CFRunLoopStop is documented as + // thread-safe; calling it before the worker has set + // worker_runloop is harmless because we just don't call + // it in that branch — the worker will exit when its + // CGEventTapCreate fails or completes. + if let Some(rl) = self.worker_runloop.lock().ok().and_then(|mut g| g.take()) { + unsafe { CFRunLoopStop(rl.0) }; + } if let Some(h) = self.worker.take() { let _ = h.join(); } @@ -280,6 +664,7 @@ impl DesktopPttBackend for MacOSEventTapBackend { fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> { self.binding = binding; + self.refresh_bound_atomics(); // Re-publish the descriptor so the bound_input_class // change (keyboard → mouse-side-button, or vice versa) // reaches the UI capability badge promptly. @@ -294,12 +679,95 @@ impl Drop for MacOSEventTapBackend { } } +// ---------- C-ABI tap callback ---------- + +/// Per-tap user data. Lives in a Box owned by the eventtap worker +/// thread; the raw pointer is passed to CGEventTapCreate as +/// `user_info`. The callback reads bound key/button atomically; +/// no Rust references cross the FFI boundary. +struct TapState { + gate: AudioTransmitGate, + bound_keycode: Arc, + bound_mouse_button: Arc, +} + +/// CGEventTap callback. Runs on the worker thread's CFRunLoop. +/// Returns the event unchanged (listen-only tap, no modification). +/// +/// Privacy invariant (DEC-027): this function reads keycodes and +/// button numbers from the event but never logs them. The only +/// log lines emitted from the macOS PTT backend reference +/// `bound_input_class` and capability level, never the raw key +/// identity. +extern "C" fn tap_callback( + _proxy: CGEventTapProxy, + etype: CGEventType, + event: CGEventRef, + user_info: *mut std::ffi::c_void, +) -> CGEventRef { + // CGEventTap can be disabled by the system at runtime (slow + // callback timeout, user input flood). In both cases we'd + // need to re-enable the tap; for now we log and the user can + // restart the app. The CGEvent docs explicitly say returning + // the event unchanged is the correct no-op for these + // notification types. + if etype == KCG_EVENT_TAP_DISABLED_BY_TIMEOUT + || etype == KCG_EVENT_TAP_DISABLED_BY_USER_INPUT + { + warn!( + target: "chanora_audio", + event = "tap_disabled", + "macos ptt: event tap disabled by system; PTT now degraded" + ); + return event; + } + + // SAFETY: user_info was set by `start()` to the Box::into_raw + // pointer of a TapState whose lifetime is bound to the + // eventtap worker thread. The thread is the sole reader of + // this pointer and only exits after CFRunLoopRun returns, + // which is before the Box is dropped. + let state = unsafe { &*(user_info as *const TapState) }; + + match etype { + KCG_EVENT_KEY_DOWN | KCG_EVENT_KEY_UP => { + let bound = state.bound_keycode.load(Ordering::Relaxed); + if bound < 0 { + return event; + } + let kc = unsafe { + CGEventGetIntegerValueField(event, KCG_KEYBOARD_EVENT_KEYCODE) + }; + if kc == bound as i64 { + let pressed = etype == KCG_EVENT_KEY_DOWN; + state.gate.set(pressed); + } + } + KCG_EVENT_OTHER_MOUSE_DOWN | KCG_EVENT_OTHER_MOUSE_UP => { + let bound = state.bound_mouse_button.load(Ordering::Relaxed); + if bound < 0 { + return event; + } + let btn = unsafe { + CGEventGetIntegerValueField(event, KCG_MOUSE_EVENT_BUTTON_NUMBER) + }; + if btn == bound as i64 { + let pressed = etype == KCG_EVENT_OTHER_MOUSE_DOWN; + state.gate.set(pressed); + } + } + _ => {} + } + event +} + #[cfg(test)] mod tests { - //! Unit tests for the macOS permission state machine and the - //! descriptor builder. The live `IOHIDCheckAccess` query is - //! covered by the macOS platform-verification commit; here we - //! exercise the parts that are platform-independent. + //! Unit tests for the macOS permission state machine, the + //! descriptor builder, and the keymap. The live + //! `IOHIDCheckAccess` / `CGEventTapCreate` calls are covered + //! by the macOS platform-acceptance pass; here we exercise + //! the parts that are platform-independent. use super::*; @@ -307,7 +775,7 @@ mod tests { fn build_descriptor_undetermined_reports_focused() { let d = MacOSEventTapBackend::build_descriptor( PermissionState::Undetermined, - super::super::PttInputClass::Keyboard, + PttInputClass::Keyboard, ); assert_eq!(d.level, PttCapabilityLevel::L0Focused); assert_eq!(d.backend_id, "event-tap"); @@ -317,7 +785,7 @@ mod tests { fn build_descriptor_denied_reports_focused() { let d = MacOSEventTapBackend::build_descriptor( PermissionState::Denied, - super::super::PttInputClass::Keyboard, + PttInputClass::Keyboard, ); assert_eq!(d.level, PttCapabilityLevel::L0Focused); } @@ -326,7 +794,7 @@ mod tests { fn build_descriptor_granted_keyboard_reports_L2() { let d = MacOSEventTapBackend::build_descriptor( PermissionState::Granted, - super::super::PttInputClass::Keyboard, + PttInputClass::Keyboard, ); assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); assert_eq!(d.bound_input_class, Some("keyboard")); @@ -336,7 +804,7 @@ mod tests { fn build_descriptor_granted_mouse_reports_L3() { let d = MacOSEventTapBackend::build_descriptor( PermissionState::Granted, - super::super::PttInputClass::MouseSideButton, + PttInputClass::MouseSideButton, ); assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons); assert_eq!(d.bound_input_class, Some("mouse-side-button")); @@ -344,12 +812,9 @@ mod tests { #[test] fn build_descriptor_granted_none_reports_L2_keyboard() { - // `PttInputClass::None` is a sentinel for "no binding". - // The descriptor still reports L2 because the permission - // is granted; the bound_input_class is None. let d = MacOSEventTapBackend::build_descriptor( PermissionState::Granted, - super::super::PttInputClass::None, + PttInputClass::None, ); assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); assert_eq!(d.bound_input_class, None); @@ -365,4 +830,46 @@ mod tests { assert_eq!(PermissionState::from_u8(s.to_u8()), s); } } + + #[test] + fn keymap_letters() { + assert_eq!(label_to_macos_keycode("Space"), Some(0x31)); + assert_eq!(label_to_macos_keycode("A"), Some(0x00)); + assert_eq!(label_to_macos_keycode("Key A"), Some(0x00)); + assert_eq!(label_to_macos_keycode("a"), Some(0x00)); + } + + #[test] + fn keymap_function_keys() { + assert_eq!(label_to_macos_keycode("F1"), Some(0x7A)); + assert_eq!(label_to_macos_keycode("F12"), Some(0x6F)); + } + + #[test] + fn keymap_navigation() { + assert_eq!(label_to_macos_keycode("Arrow Up"), Some(0x7E)); + assert_eq!(label_to_macos_keycode("ArrowUp"), Some(0x7E)); + assert_eq!(label_to_macos_keycode("Home"), Some(0x73)); + } + + #[test] + fn keymap_unknown_returns_none() { + assert_eq!(label_to_macos_keycode("PrtSc"), None); + assert_eq!(label_to_macos_keycode(""), None); + assert_eq!(label_to_macos_keycode("Mouse4"), None); + } + + #[test] + fn mouse_button_map() { + assert_eq!(label_to_macos_mouse_button("Mouse4"), Some(3)); + assert_eq!(label_to_macos_mouse_button("Mouse5"), Some(4)); + assert_eq!(label_to_macos_mouse_button("Mouse Button 4"), Some(3)); + assert_eq!(label_to_macos_mouse_button("Space"), None); + } + + #[test] + fn runloop_handle_is_send() { + fn assert_send() {} + assert_send::(); + } }