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
|
||||
//! 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>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user