Initial commit of current state
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user