Validate hotkeys and support Super alias
This commit is contained in:
parent
2cbc1a98b9
commit
3bc473262d
6 changed files with 138 additions and 34 deletions
|
|
@ -63,7 +63,7 @@ uv sync --extra x11
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Create `~/.config/aman/config.json`:
|
Create `~/.config/aman/config.json` (or let `aman` create it automatically on first start if missing):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -88,6 +88,11 @@ Create `~/.config/aman/config.json`:
|
||||||
Recording input can be a device index (preferred) or a substring of the device
|
Recording input can be a device index (preferred) or a substring of the device
|
||||||
name.
|
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
|
AI cleanup is always enabled and uses the locked local Llama-3.2-3B GGUF model
|
||||||
downloaded to `~/.cache/aman/models/` during daemon initialization.
|
downloaded to `~/.cache/aman/models/` during daemon initialization.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -400,11 +400,15 @@ def main():
|
||||||
signal.signal(signal.SIGINT, handle_signal)
|
signal.signal(signal.SIGINT, handle_signal)
|
||||||
signal.signal(signal.SIGTERM, handle_signal)
|
signal.signal(signal.SIGTERM, handle_signal)
|
||||||
|
|
||||||
|
try:
|
||||||
desktop.start_hotkey_listener(
|
desktop.start_hotkey_listener(
|
||||||
cfg.daemon.hotkey,
|
cfg.daemon.hotkey,
|
||||||
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(),
|
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(),
|
||||||
)
|
)
|
||||||
desktop.start_cancel_listener(lambda: daemon.cancel_recording())
|
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")
|
logging.info("ready")
|
||||||
try:
|
try:
|
||||||
desktop.run_tray(daemon.get_state, lambda: shutdown("quit requested"))
|
desktop.run_tray(daemon.get_state, lambda: shutdown("quit requested"))
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from constants import DEFAULT_CONFIG_PATH
|
from constants import DEFAULT_CONFIG_PATH
|
||||||
|
from hotkey import split_hotkey
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_HOTKEY = "Cmd+m"
|
DEFAULT_HOTKEY = "Cmd+m"
|
||||||
|
|
@ -76,15 +77,28 @@ def load(path: str | None) -> Config:
|
||||||
validate(cfg)
|
validate(cfg)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
validate(cfg)
|
||||||
|
_write_default_config(p, cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def redacted_dict(cfg: Config) -> dict[str, Any]:
|
def redacted_dict(cfg: Config) -> dict[str, Any]:
|
||||||
return asdict(cfg)
|
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:
|
def validate(cfg: Config) -> None:
|
||||||
hotkey = cfg.daemon.hotkey.strip()
|
hotkey = cfg.daemon.hotkey.strip()
|
||||||
if not hotkey:
|
if not hotkey:
|
||||||
raise ValueError("daemon.hotkey cannot be empty")
|
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):
|
if isinstance(cfg.recording.input, bool):
|
||||||
raise ValueError("recording.input cannot be boolean")
|
raise ValueError("recording.input cannot be boolean")
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ except ValueError:
|
||||||
from gi.repository import GLib, Gdk, Gtk # type: ignore[import-not-found]
|
from gi.repository import GLib, Gdk, Gtk # type: ignore[import-not-found]
|
||||||
|
|
||||||
from constants import ASSETS_DIR, TRAY_UPDATE_MS
|
from constants import ASSETS_DIR, TRAY_UPDATE_MS
|
||||||
|
from hotkey import split_hotkey
|
||||||
|
|
||||||
|
|
||||||
MOD_MAP = {
|
MOD_MAP = {
|
||||||
|
|
@ -65,11 +66,14 @@ class X11Adapter:
|
||||||
self.menu.popup(None, None, None, None, 0, _time)
|
self.menu.popup(None, None, None, None, 0, _time)
|
||||||
|
|
||||||
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
|
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()
|
thread.start()
|
||||||
|
|
||||||
def start_cancel_listener(self, callback: Callable[[], None]) -> None:
|
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()
|
thread.start()
|
||||||
|
|
||||||
def inject_text(
|
def inject_text(
|
||||||
|
|
@ -123,14 +127,13 @@ class X11Adapter:
|
||||||
finally:
|
finally:
|
||||||
self.request_quit()
|
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
|
disp = None
|
||||||
root = None
|
root = None
|
||||||
keycode = None
|
keycode = None
|
||||||
try:
|
try:
|
||||||
disp = display.Display()
|
disp = display.Display()
|
||||||
root = disp.screen().root
|
root = disp.screen().root
|
||||||
mods, keysym = self._parse_hotkey(hotkey)
|
|
||||||
keycode = self._grab_hotkey(disp, root, mods, keysym)
|
keycode = self._grab_hotkey(disp, root, mods, keysym)
|
||||||
while True:
|
while True:
|
||||||
ev = disp.next_event()
|
ev = disp.next_event()
|
||||||
|
|
@ -149,17 +152,10 @@ class X11Adapter:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _parse_hotkey(self, hotkey: str):
|
def _parse_hotkey(self, hotkey: str):
|
||||||
parts = [p.strip() for p in hotkey.split("+") if p.strip()]
|
|
||||||
mods = 0
|
mods = 0
|
||||||
key_part = None
|
modifier_parts, key_part = split_hotkey(hotkey)
|
||||||
for p in parts:
|
for modifier in modifier_parts:
|
||||||
low = p.lower()
|
mods |= MOD_MAP[modifier]
|
||||||
if low in MOD_MAP:
|
|
||||||
mods |= MOD_MAP[low]
|
|
||||||
else:
|
|
||||||
key_part = p
|
|
||||||
if not key_part:
|
|
||||||
raise ValueError("hotkey missing key")
|
|
||||||
|
|
||||||
keysym = XK.string_to_keysym(key_part)
|
keysym = XK.string_to_keysym(key_part)
|
||||||
if keysym == 0 and len(key_part) == 1:
|
if keysym == 0 and len(key_part) == 1:
|
||||||
|
|
@ -168,8 +164,31 @@ class X11Adapter:
|
||||||
raise ValueError(f"unsupported key: {key_part}")
|
raise ValueError(f"unsupported key: {key_part}")
|
||||||
return mods, keysym
|
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):
|
def _grab_hotkey(self, disp, root, mods, keysym):
|
||||||
keycode = disp.keysym_to_keycode(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, True, X.GrabModeAsync, X.GrabModeAsync)
|
||||||
root.grab_key(keycode, mods | X.LockMask, 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)
|
root.grab_key(keycode, mods | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync)
|
||||||
|
|
|
||||||
30
src/hotkey.py
Normal file
30
src/hotkey.py
Normal 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
|
||||||
|
|
@ -9,15 +9,13 @@ SRC = ROOT / "src"
|
||||||
if str(SRC) not in sys.path:
|
if str(SRC) not in sys.path:
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
from config import load
|
from config import load, redacted_dict
|
||||||
|
|
||||||
|
|
||||||
class ConfigTests(unittest.TestCase):
|
class ConfigTests(unittest.TestCase):
|
||||||
def test_defaults_when_file_missing(self):
|
def test_defaults_when_file_missing(self):
|
||||||
missing = Path(tempfile.gettempdir()) / "lel_missing_config_test.json"
|
with tempfile.TemporaryDirectory() as td:
|
||||||
if missing.exists():
|
missing = Path(td) / "nested" / "config.json"
|
||||||
missing.unlink()
|
|
||||||
|
|
||||||
cfg = load(str(missing))
|
cfg = load(str(missing))
|
||||||
|
|
||||||
self.assertEqual(cfg.daemon.hotkey, "Cmd+m")
|
self.assertEqual(cfg.daemon.hotkey, "Cmd+m")
|
||||||
|
|
@ -30,6 +28,10 @@ class ConfigTests(unittest.TestCase):
|
||||||
self.assertEqual(cfg.vocabulary.terms, [])
|
self.assertEqual(cfg.vocabulary.terms, [])
|
||||||
self.assertTrue(cfg.domain_inference.enabled)
|
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):
|
def test_loads_nested_config(self):
|
||||||
payload = {
|
payload = {
|
||||||
"daemon": {"hotkey": "Ctrl+space"},
|
"daemon": {"hotkey": "Ctrl+space"},
|
||||||
|
|
@ -66,6 +68,36 @@ class ConfigTests(unittest.TestCase):
|
||||||
self.assertEqual(cfg.vocabulary.terms, ["Systemd", "Kubernetes"])
|
self.assertEqual(cfg.vocabulary.terms, ["Systemd", "Kubernetes"])
|
||||||
self.assertTrue(cfg.domain_inference.enabled)
|
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):
|
def test_loads_legacy_keys(self):
|
||||||
payload = {
|
payload = {
|
||||||
"hotkey": "Alt+m",
|
"hotkey": "Alt+m",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue