137 lines
4.8 KiB
Python
137 lines
4.8 KiB
Python
"""Watch a modifier key system-wide, so push-to-talk works from any app.
|
|
|
|
Reading stdin only works while the terminal has focus, which defeats the point
|
|
of a voice assistant you talk to while doing something else. A Quartz event tap
|
|
sees key events no matter what is frontmost, and — unlike stdin — reports
|
|
releases as well as presses, so this can be true hold-to-talk.
|
|
|
|
The tap runs on its own thread with its own CFRunLoop and hands state changes
|
|
back to asyncio via ``call_soon_threadsafe``.
|
|
|
|
Requires Input Monitoring permission for whichever app runs this (your
|
|
terminal), granted under System Settings > Privacy & Security > Input
|
|
Monitoring.
|
|
"""
|
|
|
|
import asyncio
|
|
import threading
|
|
from collections.abc import Callable
|
|
|
|
from loguru import logger
|
|
|
|
import Quartz
|
|
from CoreFoundation import (
|
|
CFMachPortCreateRunLoopSource,
|
|
CFRunLoopAddSource,
|
|
CFRunLoopGetCurrent,
|
|
CFRunLoopRun,
|
|
CFRunLoopStop,
|
|
kCFRunLoopCommonModes,
|
|
)
|
|
|
|
# (keycode, modifier mask) for the keys worth holding. Modifier keycodes arrive
|
|
# on flagsChanged events, so one handler covers all of them.
|
|
HOLD_KEYS: dict[str, tuple[int, int]] = {
|
|
"fn": (63, Quartz.kCGEventFlagMaskSecondaryFn),
|
|
"right-option": (61, Quartz.kCGEventFlagMaskAlternate),
|
|
"right-command": (54, Quartz.kCGEventFlagMaskCommand),
|
|
"right-control": (62, Quartz.kCGEventFlagMaskControl),
|
|
"right-shift": (60, Quartz.kCGEventFlagMaskShift),
|
|
}
|
|
|
|
|
|
def permission_granted() -> bool:
|
|
"""Whether this process may observe keyboard events."""
|
|
return bool(Quartz.CGPreflightListenEventAccess())
|
|
|
|
|
|
def request_permission() -> bool:
|
|
"""Ask for Input Monitoring, which prompts once and then opens Settings."""
|
|
return bool(Quartz.CGRequestListenEventAccess())
|
|
|
|
|
|
class HoldKeyMonitor:
|
|
"""Report press and release of one modifier key, from anywhere in the OS.
|
|
|
|
Args:
|
|
key: A name from ``HOLD_KEYS``.
|
|
on_change: Called with True on press and False on release, on the
|
|
asyncio loop.
|
|
loop: The loop to deliver callbacks on.
|
|
"""
|
|
|
|
def __init__(self, *, key: str, on_change: Callable[[bool], None], loop):
|
|
if key not in HOLD_KEYS:
|
|
raise ValueError(f"Unsupported hold key {key!r}. Choose from {list(HOLD_KEYS)}.")
|
|
self._key = key
|
|
self._keycode, self._mask = HOLD_KEYS[key]
|
|
self._on_change = on_change
|
|
self._loop = loop
|
|
self._thread: threading.Thread | None = None
|
|
self._runloop = None
|
|
self._tap = None
|
|
self._down = False
|
|
self._ready = threading.Event()
|
|
self._started_ok = False
|
|
|
|
def start(self) -> bool:
|
|
"""Begin watching. Returns False if the tap could not be created."""
|
|
self._thread = threading.Thread(target=self._run, name="hold-key-tap", daemon=True)
|
|
self._thread.start()
|
|
self._ready.wait(timeout=5)
|
|
return self._started_ok
|
|
|
|
def stop(self):
|
|
if self._runloop is not None:
|
|
CFRunLoopStop(self._runloop)
|
|
self._runloop = None
|
|
|
|
def _run(self):
|
|
tap = Quartz.CGEventTapCreate(
|
|
Quartz.kCGSessionEventTap,
|
|
Quartz.kCGHeadInsertEventTap,
|
|
# Listen only: we observe the key without swallowing it, so we never
|
|
# break whatever else the user has bound to it.
|
|
Quartz.kCGEventTapOptionListenOnly,
|
|
Quartz.CGEventMaskBit(Quartz.kCGEventFlagsChanged),
|
|
self._callback,
|
|
None,
|
|
)
|
|
if tap is None:
|
|
self._started_ok = False
|
|
self._ready.set()
|
|
return
|
|
|
|
self._tap = tap
|
|
source = CFMachPortCreateRunLoopSource(None, tap, 0)
|
|
self._runloop = CFRunLoopGetCurrent()
|
|
CFRunLoopAddSource(self._runloop, source, kCFRunLoopCommonModes)
|
|
Quartz.CGEventTapEnable(tap, True)
|
|
|
|
self._started_ok = True
|
|
self._ready.set()
|
|
CFRunLoopRun()
|
|
|
|
def _callback(self, proxy, event_type, event, refcon):
|
|
# macOS disables a tap that takes too long; turn it back on.
|
|
if event_type in (
|
|
Quartz.kCGEventTapDisabledByTimeout,
|
|
Quartz.kCGEventTapDisabledByUserInput,
|
|
):
|
|
if self._tap is not None:
|
|
Quartz.CGEventTapEnable(self._tap, True)
|
|
return event
|
|
|
|
try:
|
|
keycode = Quartz.CGEventGetIntegerValueField(
|
|
event, Quartz.kCGKeyboardEventKeycode
|
|
)
|
|
if keycode == self._keycode:
|
|
down = bool(Quartz.CGEventGetFlags(event) & self._mask)
|
|
if down != self._down:
|
|
self._down = down
|
|
self._loop.call_soon_threadsafe(self._on_change, down)
|
|
except Exception as e: # never let an exception cross back into C
|
|
logger.debug(f"Hold-key tap callback error: {e}")
|
|
return event
|