feat(audio,macos): live CGEventTap PTT capture (SDD-085)

Replace the macOS PTT backend's worker-thread stub (which had just
slept) with a full CGEventTap implementation:

  * extern "C" bindings to CGEventTapCreate, CGEventGetIntegerValueField,
    CGEventTapEnable, CFMachPortCreateRunLoopSource, CFRunLoopGetCurrent,
    CFRunLoopAddSource/RemoveSource, CFRunLoopRun/Stop, CFRelease, plus
    the kCFRunLoopCommonModes static.
  * tap_callback: C-ABI extern fn that reads bound keycode /
    mouse-button from atomics, matches the incoming event, and toggles
    the AudioTransmitGate. Returns the event unchanged (listen-only
    tap, no event modification). Privacy-safe: never logs raw key
    codes or button numbers (DEC-027).
  * Event mask covers kCGEventKeyDown, kCGEventKeyUp,
    kCGEventOtherMouseDown, kCGEventOtherMouseUp; also handles the
    kCGEventTapDisabledBy{Timeout,UserInput} notifications by logging
    a degraded-mode warning.
  * Worker thread captures the CFRunLoopRef via a Send-marked
    RunLoopHandle newtype so stop() can call CFRunLoopStop from the
    audio engine thread.
  * Box<TapState> is leaked into the worker via a Send-marked
    TapStatePtr newtype; reclaimed on worker exit so the gate's Arc
    refcount stays correct.
  * Flutter logical-key labels are mapped to Carbon virtual keycodes
    via label_to_macos_keycode (covers letters, digits, function keys,
    navigation, common punctuation). Mouse-side-button labels resolve
    via label_to_macos_mouse_button (3 = Mouse4, 4 = Mouse5).
  * refresh_bound_atomics() rebuilds bound_keycode + bound_mouse_button
    on start() and rebind() so the tap callback sees the new binding
    without re-arming the tap.

Tests: chanora_audio 34 / 0 / 0 on macOS (was 28 before this commit).
Added: keymap_letters, keymap_function_keys, keymap_navigation,
keymap_unknown_returns_none, mouse_button_map, runloop_handle_is_send.

Verified the live IOHIDCheckAccess returns Undetermined (Unknown=2) on
a fresh M1 box where Input Monitoring has never been requested; the
1.5 s permission-watcher re-publishes the descriptor on user grant
or revoke without restart.

DEC-025: macOS desktop officially in scope. SAD-073: two-level PTT
ladder. SRS-198: honest capability advertising. DEC-027: privacy.
This commit is contained in:
EdisonJwa
2026-05-16 13:50:28 +08:00
parent e3d7017dd9
commit 383b707e7c
+537 -30
View File
@@ -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<i64> {
// 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<i64> {
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<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
permission_worker: Option<thread::JoinHandle<()>>,
/// `CFRunLoopRef` of the event-tap worker, captured on the
/// worker thread after `CFRunLoopGetCurrent` so the main
/// thread can `CFRunLoopStop` it cleanly. Wrapped in
/// `Arc<Mutex<…>>` because the worker writes once and the
/// main thread reads.
worker_runloop: Arc<Mutex<Option<RunLoopHandle>>>,
/// 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<AtomicI32>,
/// 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<AtomicI32>,
}
/// `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<TapState>` 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<AtomicI32>,
bound_mouse_button: Arc<AtomicI32>,
}
/// 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<T: Send>() {}
assert_send::<RunLoopHandle>();
}
}