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:
@@ -15,9 +15,23 @@
|
|||||||
//! channel — the supervisor in `chanora_core` forwards the
|
//! channel — the supervisor in `chanora_core` forwards the
|
||||||
//! transition to `SessionEvent::PttCapability` and the Flutter
|
//! transition to `SessionEvent::PttCapability` and the Flutter
|
||||||
//! capability badge updates without a restart.
|
//! 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::atomic::{AtomicBool, AtomicI32, AtomicU8, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -25,10 +39,94 @@ use tokio::sync::watch;
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding,
|
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass,
|
||||||
};
|
};
|
||||||
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum PermissionState {
|
enum PermissionState {
|
||||||
Granted,
|
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`
|
/// Try to construct the macOS event-tap backend. Returns `None`
|
||||||
/// when the permission is denied (the caller then falls back to
|
/// when the permission is denied (the caller then falls back to
|
||||||
/// the universal Focused backend); returns `Some` for `Granted`
|
/// the universal Focused backend); returns `Some` for `Granted`
|
||||||
@@ -123,8 +337,48 @@ pub struct MacOSEventTapBackend {
|
|||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
worker: Option<thread::JoinHandle<()>>,
|
worker: Option<thread::JoinHandle<()>>,
|
||||||
permission_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 {
|
impl MacOSEventTapBackend {
|
||||||
fn new(permission: PermissionState) -> Self {
|
fn new(permission: PermissionState) -> Self {
|
||||||
let initial = PttBackendDescriptor {
|
let initial = PttBackendDescriptor {
|
||||||
@@ -145,6 +399,9 @@ impl MacOSEventTapBackend {
|
|||||||
stop: Arc::new(AtomicBool::new(false)),
|
stop: Arc::new(AtomicBool::new(false)),
|
||||||
worker: None,
|
worker: None,
|
||||||
permission_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).
|
/// (used by `descriptor()` and the re-query worker).
|
||||||
fn build_descriptor(
|
fn build_descriptor(
|
||||||
permission: PermissionState,
|
permission: PermissionState,
|
||||||
class: super::PttInputClass,
|
class: PttInputClass,
|
||||||
) -> PttBackendDescriptor {
|
) -> PttBackendDescriptor {
|
||||||
let level = match permission {
|
let level = match permission {
|
||||||
PermissionState::Granted => match class {
|
PermissionState::Granted => match class {
|
||||||
super::PttInputClass::MouseSideButton => {
|
PttInputClass::MouseSideButton => {
|
||||||
PttCapabilityLevel::L3GlobalWithMouseButtons
|
PttCapabilityLevel::L3GlobalWithMouseButtons
|
||||||
}
|
}
|
||||||
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
|
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
|
||||||
@@ -171,12 +428,30 @@ impl MacOSEventTapBackend {
|
|||||||
level,
|
level,
|
||||||
backend_id: "event-tap",
|
backend_id: "event-tap",
|
||||||
bound_input_class: match class {
|
bound_input_class: match class {
|
||||||
super::PttInputClass::None => None,
|
PttInputClass::None => None,
|
||||||
super::PttInputClass::MouseSideButton => Some("mouse-side-button"),
|
PttInputClass::MouseSideButton => Some("mouse-side-button"),
|
||||||
_ => Some("keyboard"),
|
_ => 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 {
|
impl DesktopPttBackend for MacOSEventTapBackend {
|
||||||
@@ -196,26 +471,126 @@ impl DesktopPttBackend for MacOSEventTapBackend {
|
|||||||
) -> Result<(), PttBackendError> {
|
) -> Result<(), PttBackendError> {
|
||||||
self.gate = Some(gate.clone());
|
self.gate = Some(gate.clone());
|
||||||
self.binding = binding;
|
self.binding = binding;
|
||||||
|
self.refresh_bound_atomics();
|
||||||
let stop = self.stop.clone();
|
let stop = self.stop.clone();
|
||||||
|
|
||||||
// Spawn the event-tap worker. Live `CGEventTapCreate` +
|
// ----- Event-tap worker -----
|
||||||
// run-loop wiring lands in the macOS platform
|
//
|
||||||
// verification commit; the scaffolding keeps the
|
// Captures a `Box<TapState>` whose raw pointer is passed
|
||||||
// lifecycle correct so the watchdog + capability event
|
// to `CGEventTapCreate` as user_info. The C-ABI callback
|
||||||
// are exercisable now.
|
// 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()
|
let handle = thread::Builder::new()
|
||||||
.name("chanora-eventtap".into())
|
.name("chanora-eventtap".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
while !stop.load(Ordering::Relaxed) {
|
// SAFETY: the worker is the sole owner of the
|
||||||
thread::sleep(Duration::from_millis(50));
|
// 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}")))?;
|
.map_err(|e| PttBackendError::Init(format!("eventtap thread: {e}")))?;
|
||||||
self.worker = Some(handle);
|
self.worker = Some(handle);
|
||||||
|
|
||||||
// Spawn the permission re-query worker (SRS-198 runtime
|
// ----- Permission re-query worker (SRS-198) -----
|
||||||
// upgrade hook). Polls every 1.5 s; on transition,
|
//
|
||||||
// republishes the descriptor through the watch sender so
|
// Polls every 1.5 s; on transition, republishes the
|
||||||
|
// descriptor through the watch sender so
|
||||||
// `chanora_core::ChanoraSession::start_audio`'s forwarder
|
// `chanora_core::ChanoraSession::start_audio`'s forwarder
|
||||||
// re-emits `SessionEvent::PttCapability` and the Flutter
|
// re-emits `SessionEvent::PttCapability` and the Flutter
|
||||||
// capability badge updates without a restart.
|
// capability badge updates without a restart.
|
||||||
@@ -261,12 +636,21 @@ impl DesktopPttBackend for MacOSEventTapBackend {
|
|||||||
// than after the first re-query tick.
|
// than after the first re-query tick.
|
||||||
let _ = self.desc_tx.send(self.descriptor());
|
let _ = self.desc_tx.send(self.descriptor());
|
||||||
|
|
||||||
let _ = &gate; // captured for future live wiring
|
let _ = &gate; // captured by TapState
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) {
|
fn stop(&mut self) {
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
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() {
|
if let Some(h) = self.worker.take() {
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
@@ -280,6 +664,7 @@ impl DesktopPttBackend for MacOSEventTapBackend {
|
|||||||
|
|
||||||
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
|
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
|
||||||
self.binding = binding;
|
self.binding = binding;
|
||||||
|
self.refresh_bound_atomics();
|
||||||
// Re-publish the descriptor so the bound_input_class
|
// Re-publish the descriptor so the bound_input_class
|
||||||
// change (keyboard → mouse-side-button, or vice versa)
|
// change (keyboard → mouse-side-button, or vice versa)
|
||||||
// reaches the UI capability badge promptly.
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
//! Unit tests for the macOS permission state machine and the
|
//! Unit tests for the macOS permission state machine, the
|
||||||
//! descriptor builder. The live `IOHIDCheckAccess` query is
|
//! descriptor builder, and the keymap. The live
|
||||||
//! covered by the macOS platform-verification commit; here we
|
//! `IOHIDCheckAccess` / `CGEventTapCreate` calls are covered
|
||||||
//! exercise the parts that are platform-independent.
|
//! by the macOS platform-acceptance pass; here we exercise
|
||||||
|
//! the parts that are platform-independent.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -307,7 +775,7 @@ mod tests {
|
|||||||
fn build_descriptor_undetermined_reports_focused() {
|
fn build_descriptor_undetermined_reports_focused() {
|
||||||
let d = MacOSEventTapBackend::build_descriptor(
|
let d = MacOSEventTapBackend::build_descriptor(
|
||||||
PermissionState::Undetermined,
|
PermissionState::Undetermined,
|
||||||
super::super::PttInputClass::Keyboard,
|
PttInputClass::Keyboard,
|
||||||
);
|
);
|
||||||
assert_eq!(d.level, PttCapabilityLevel::L0Focused);
|
assert_eq!(d.level, PttCapabilityLevel::L0Focused);
|
||||||
assert_eq!(d.backend_id, "event-tap");
|
assert_eq!(d.backend_id, "event-tap");
|
||||||
@@ -317,7 +785,7 @@ mod tests {
|
|||||||
fn build_descriptor_denied_reports_focused() {
|
fn build_descriptor_denied_reports_focused() {
|
||||||
let d = MacOSEventTapBackend::build_descriptor(
|
let d = MacOSEventTapBackend::build_descriptor(
|
||||||
PermissionState::Denied,
|
PermissionState::Denied,
|
||||||
super::super::PttInputClass::Keyboard,
|
PttInputClass::Keyboard,
|
||||||
);
|
);
|
||||||
assert_eq!(d.level, PttCapabilityLevel::L0Focused);
|
assert_eq!(d.level, PttCapabilityLevel::L0Focused);
|
||||||
}
|
}
|
||||||
@@ -326,7 +794,7 @@ mod tests {
|
|||||||
fn build_descriptor_granted_keyboard_reports_L2() {
|
fn build_descriptor_granted_keyboard_reports_L2() {
|
||||||
let d = MacOSEventTapBackend::build_descriptor(
|
let d = MacOSEventTapBackend::build_descriptor(
|
||||||
PermissionState::Granted,
|
PermissionState::Granted,
|
||||||
super::super::PttInputClass::Keyboard,
|
PttInputClass::Keyboard,
|
||||||
);
|
);
|
||||||
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
||||||
assert_eq!(d.bound_input_class, Some("keyboard"));
|
assert_eq!(d.bound_input_class, Some("keyboard"));
|
||||||
@@ -336,7 +804,7 @@ mod tests {
|
|||||||
fn build_descriptor_granted_mouse_reports_L3() {
|
fn build_descriptor_granted_mouse_reports_L3() {
|
||||||
let d = MacOSEventTapBackend::build_descriptor(
|
let d = MacOSEventTapBackend::build_descriptor(
|
||||||
PermissionState::Granted,
|
PermissionState::Granted,
|
||||||
super::super::PttInputClass::MouseSideButton,
|
PttInputClass::MouseSideButton,
|
||||||
);
|
);
|
||||||
assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons);
|
assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons);
|
||||||
assert_eq!(d.bound_input_class, Some("mouse-side-button"));
|
assert_eq!(d.bound_input_class, Some("mouse-side-button"));
|
||||||
@@ -344,12 +812,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_descriptor_granted_none_reports_L2_keyboard() {
|
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(
|
let d = MacOSEventTapBackend::build_descriptor(
|
||||||
PermissionState::Granted,
|
PermissionState::Granted,
|
||||||
super::super::PttInputClass::None,
|
PttInputClass::None,
|
||||||
);
|
);
|
||||||
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
||||||
assert_eq!(d.bound_input_class, None);
|
assert_eq!(d.bound_input_class, None);
|
||||||
@@ -365,4 +830,46 @@ mod tests {
|
|||||||
assert_eq!(PermissionState::from_u8(s.to_u8()), s);
|
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>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user