From 3bc473262d53c629999dfccd339e205082dce6f6 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Wed, 25 Feb 2026 11:51:39 -0300 Subject: [PATCH] Validate hotkeys and support Super alias --- README.md | 7 +++++- src/aman.py | 14 +++++++---- src/config.py | 14 +++++++++++ src/desktop_x11.py | 47 +++++++++++++++++++++++----------- src/hotkey.py | 30 ++++++++++++++++++++++ tests/test_config.py | 60 +++++++++++++++++++++++++++++++++----------- 6 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 src/hotkey.py diff --git a/README.md b/README.md index f38b080..c7510b1 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ uv sync --extra x11 ## Config -Create `~/.config/aman/config.json`: +Create `~/.config/aman/config.json` (or let `aman` create it automatically on first start if missing): ```json { @@ -88,6 +88,11 @@ Create `~/.config/aman/config.json`: Recording input can be a device index (preferred) or a substring of the device name. +Hotkey notes: + +- Use one key plus optional modifiers (for example `Cmd+m`, `Super+m`, `Ctrl+space`). +- `Super` and `Cmd` are equivalent aliases for the same modifier. + AI cleanup is always enabled and uses the locked local Llama-3.2-3B GGUF model downloaded to `~/.cache/aman/models/` during daemon initialization. diff --git a/src/aman.py b/src/aman.py index 0ea7a2c..0ffbc20 100755 --- a/src/aman.py +++ b/src/aman.py @@ -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")) diff --git a/src/config.py b/src/config.py index 29e12d4..6236966 100644 --- a/src/config.py +++ b/src/config.py @@ -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") diff --git a/src/desktop_x11.py b/src/desktop_x11.py index 9f60a83..d79ca0d 100644 --- a/src/desktop_x11.py +++ b/src/desktop_x11.py @@ -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) diff --git a/src/hotkey.py b/src/hotkey.py new file mode 100644 index 0000000..7d3b3f4 --- /dev/null +++ b/src/hotkey.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index 14d07f5..f65a9f8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,26 +9,28 @@ SRC = ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) -from config import load +from config import load, redacted_dict class ConfigTests(unittest.TestCase): def test_defaults_when_file_missing(self): - missing = Path(tempfile.gettempdir()) / "lel_missing_config_test.json" - if missing.exists(): - missing.unlink() + with tempfile.TemporaryDirectory() as td: + missing = Path(td) / "nested" / "config.json" + cfg = load(str(missing)) - cfg = load(str(missing)) + self.assertEqual(cfg.daemon.hotkey, "Cmd+m") + self.assertEqual(cfg.recording.input, "") + self.assertEqual(cfg.stt.model, "base") + self.assertEqual(cfg.stt.device, "cpu") + self.assertEqual(cfg.injection.backend, "clipboard") + self.assertFalse(cfg.injection.remove_transcription_from_clipboard) + self.assertEqual(cfg.vocabulary.replacements, []) + self.assertEqual(cfg.vocabulary.terms, []) + self.assertTrue(cfg.domain_inference.enabled) - self.assertEqual(cfg.daemon.hotkey, "Cmd+m") - self.assertEqual(cfg.recording.input, "") - self.assertEqual(cfg.stt.model, "base") - self.assertEqual(cfg.stt.device, "cpu") - self.assertEqual(cfg.injection.backend, "clipboard") - self.assertFalse(cfg.injection.remove_transcription_from_clipboard) - self.assertEqual(cfg.vocabulary.replacements, []) - self.assertEqual(cfg.vocabulary.terms, []) - self.assertTrue(cfg.domain_inference.enabled) + self.assertTrue(missing.exists()) + written = json.loads(missing.read_text(encoding="utf-8")) + self.assertEqual(written, redacted_dict(cfg)) def test_loads_nested_config(self): payload = { @@ -66,6 +68,36 @@ class ConfigTests(unittest.TestCase): self.assertEqual(cfg.vocabulary.terms, ["Systemd", "Kubernetes"]) self.assertTrue(cfg.domain_inference.enabled) + def test_super_modifier_hotkey_is_valid(self): + payload = {"daemon": {"hotkey": "Super+m"}} + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + cfg = load(str(path)) + + self.assertEqual(cfg.daemon.hotkey, "Super+m") + + def test_invalid_hotkey_missing_key_raises(self): + payload = {"daemon": {"hotkey": "Ctrl+Alt"}} + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "daemon.hotkey is invalid: missing key"): + load(str(path)) + + def test_invalid_hotkey_multiple_keys_raises(self): + payload = {"daemon": {"hotkey": "Ctrl+a+b"}} + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + with self.assertRaisesRegex( + ValueError, "daemon.hotkey is invalid: must include exactly one non-modifier key" + ): + load(str(path)) + def test_loads_legacy_keys(self): payload = { "hotkey": "Alt+m",