Validate hotkeys and support Super alias

This commit is contained in:
Thales Maciel 2026-02-25 11:51:39 -03:00
parent 2cbc1a98b9
commit 3bc473262d
6 changed files with 138 additions and 34 deletions

View file

@ -400,11 +400,15 @@ def main():
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
desktop.start_hotkey_listener(
cfg.daemon.hotkey,
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(),
)
desktop.start_cancel_listener(lambda: daemon.cancel_recording())
try:
desktop.start_hotkey_listener(
cfg.daemon.hotkey,
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(),
)
desktop.start_cancel_listener(lambda: daemon.cancel_recording())
except Exception as exc:
logging.error("hotkey setup failed: %s", exc)
raise SystemExit(1)
logging.info("ready")
try:
desktop.run_tray(daemon.get_state, lambda: shutdown("quit requested"))

View file

@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any
from constants import DEFAULT_CONFIG_PATH
from hotkey import split_hotkey
DEFAULT_HOTKEY = "Cmd+m"
@ -73,7 +74,11 @@ def load(path: str | None) -> Config:
if not isinstance(data, dict):
raise ValueError("config must be a JSON object")
cfg = _from_dict(data, cfg)
validate(cfg)
return cfg
validate(cfg)
_write_default_config(p, cfg)
return cfg
@ -81,10 +86,19 @@ def redacted_dict(cfg: Config) -> dict[str, Any]:
return asdict(cfg)
def _write_default_config(path: Path, cfg: Config) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"{json.dumps(redacted_dict(cfg), indent=2)}\n", encoding="utf-8")
def validate(cfg: Config) -> None:
hotkey = cfg.daemon.hotkey.strip()
if not hotkey:
raise ValueError("daemon.hotkey cannot be empty")
try:
split_hotkey(hotkey)
except ValueError as exc:
raise ValueError(f"daemon.hotkey is invalid: {exc}") from exc
if isinstance(cfg.recording.input, bool):
raise ValueError("recording.input cannot be boolean")

View file

@ -21,6 +21,7 @@ except ValueError:
from gi.repository import GLib, Gdk, Gtk # type: ignore[import-not-found]
from constants import ASSETS_DIR, TRAY_UPDATE_MS
from hotkey import split_hotkey
MOD_MAP = {
@ -65,11 +66,14 @@ class X11Adapter:
self.menu.popup(None, None, None, None, 0, _time)
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
thread = threading.Thread(target=self._listen, args=(hotkey, callback), daemon=True)
mods, keysym = self._parse_hotkey(hotkey)
self._validate_hotkey_registration(mods, keysym)
thread = threading.Thread(target=self._listen, args=(mods, keysym, callback), daemon=True)
thread.start()
def start_cancel_listener(self, callback: Callable[[], None]) -> None:
thread = threading.Thread(target=self._listen, args=("Escape", callback), daemon=True)
mods, keysym = self._parse_hotkey("Escape")
thread = threading.Thread(target=self._listen, args=(mods, keysym, callback), daemon=True)
thread.start()
def inject_text(
@ -123,14 +127,13 @@ class X11Adapter:
finally:
self.request_quit()
def _listen(self, hotkey: str, callback: Callable[[], None]) -> None:
def _listen(self, mods: int, keysym: int, callback: Callable[[], None]) -> None:
disp = None
root = None
keycode = None
try:
disp = display.Display()
root = disp.screen().root
mods, keysym = self._parse_hotkey(hotkey)
keycode = self._grab_hotkey(disp, root, mods, keysym)
while True:
ev = disp.next_event()
@ -149,17 +152,10 @@ class X11Adapter:
pass
def _parse_hotkey(self, hotkey: str):
parts = [p.strip() for p in hotkey.split("+") if p.strip()]
mods = 0
key_part = None
for p in parts:
low = p.lower()
if low in MOD_MAP:
mods |= MOD_MAP[low]
else:
key_part = p
if not key_part:
raise ValueError("hotkey missing key")
modifier_parts, key_part = split_hotkey(hotkey)
for modifier in modifier_parts:
mods |= MOD_MAP[modifier]
keysym = XK.string_to_keysym(key_part)
if keysym == 0 and len(key_part) == 1:
@ -168,8 +164,31 @@ class X11Adapter:
raise ValueError(f"unsupported key: {key_part}")
return mods, keysym
def _validate_hotkey_registration(self, mods: int, keysym: int) -> None:
disp = None
root = None
keycode = None
try:
disp = display.Display()
root = disp.screen().root
keycode = self._grab_hotkey(disp, root, mods, keysym)
finally:
if root is not None and keycode is not None and disp is not None:
try:
root.ungrab_key(keycode, X.AnyModifier)
disp.sync()
except Exception:
pass
if disp is not None:
try:
disp.close()
except Exception:
pass
def _grab_hotkey(self, disp, root, mods, keysym):
keycode = disp.keysym_to_keycode(keysym)
if keycode == 0:
raise ValueError("hotkey is not available on this keyboard layout")
root.grab_key(keycode, mods, True, X.GrabModeAsync, X.GrabModeAsync)
root.grab_key(keycode, mods | X.LockMask, True, X.GrabModeAsync, X.GrabModeAsync)
root.grab_key(keycode, mods | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync)

30
src/hotkey.py Normal file
View file

@ -0,0 +1,30 @@
from __future__ import annotations
HOTKEY_MODIFIERS = {
"shift",
"ctrl",
"control",
"alt",
"mod1",
"super",
"mod4",
"cmd",
"command",
}
def split_hotkey(hotkey: str) -> tuple[list[str], str]:
parts = [p.strip() for p in hotkey.split("+") if p.strip()]
modifiers: list[str] = []
key_part = ""
for part in parts:
low = part.lower()
if low in HOTKEY_MODIFIERS:
modifiers.append(low)
continue
if key_part:
raise ValueError("must include exactly one non-modifier key")
key_part = part
if not key_part:
raise ValueError("missing key")
return modifiers, key_part