feat: Linux-Hotkey-Record via evdev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 21:16:21 +02:00
parent f380828309
commit 80a01903e8
2 changed files with 178 additions and 0 deletions
+107
View File
@@ -268,3 +268,110 @@ class TestCreateTray:
assert AppState.WAITING is not None
assert AppState.RECORDING is not None
assert AppState.TRANSCRIBING is not None
@pytest.mark.skipif(sys.platform != "linux", reason="evdev-Record nur auf Linux")
class TestRecordHotkeyEvdev:
def test_first_keydown_triggers_on_result(self):
import threading
from unittest.mock import MagicMock, patch
fake_event = MagicMock()
fake_event.type = 1 # EV_KEY
fake_event.code = 88 # KEY_F12
fake_event.value = 1 # Keydown
fake_device = MagicMock()
fake_device.fd = 42
fake_device.read.return_value = iter([fake_event])
fake_device.close = MagicMock()
cancel = threading.Event()
results: list[tuple[str, bool]] = []
def on_result(name, conflict):
results.append((name, conflict))
with patch(
"whisper_local.tray._hotkey_record_evdev.find_all_keyboards",
return_value=[fake_device],
), patch(
"whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector"
) as mock_sel_cls:
mock_sel = MagicMock()
mock_sel_cls.return_value = mock_sel
sel_key = MagicMock()
sel_key.data = fake_device
mock_sel.select.return_value = [(sel_key, None)]
from whisper_local.tray._hotkey_record_evdev import record_hotkey
record_hotkey(on_result, cancel)
assert results == [("KEY_F12", False)]
fake_device.close.assert_called_once()
def test_cancel_event_stops_recording(self):
import threading
from unittest.mock import MagicMock, patch
fake_device = MagicMock()
fake_device.fd = 42
fake_device.read.return_value = iter([])
fake_device.close = MagicMock()
cancel = threading.Event()
cancel.set()
results: list[tuple[str, bool]] = []
with patch(
"whisper_local.tray._hotkey_record_evdev.find_all_keyboards",
return_value=[fake_device],
), patch(
"whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector"
) as mock_sel_cls:
mock_sel = MagicMock()
mock_sel_cls.return_value = mock_sel
mock_sel.select.return_value = []
from whisper_local.tray._hotkey_record_evdev import record_hotkey
record_hotkey(lambda n, c: results.append((n, c)), cancel)
assert results == []
fake_device.close.assert_called_once()
def test_ignores_key_up_events(self):
import threading
from unittest.mock import MagicMock, patch
key_up = MagicMock()
key_up.type = 1
key_up.code = 88
key_up.value = 0
key_down = MagicMock()
key_down.type = 1
key_down.code = 88
key_down.value = 1
fake_device = MagicMock()
fake_device.fd = 42
fake_device.read.return_value = iter([key_up, key_down])
fake_device.close = MagicMock()
cancel = threading.Event()
results: list[tuple[str, bool]] = []
with patch(
"whisper_local.tray._hotkey_record_evdev.find_all_keyboards",
return_value=[fake_device],
), patch(
"whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector"
) as mock_sel_cls:
mock_sel = MagicMock()
mock_sel_cls.return_value = mock_sel
sel_key = MagicMock()
sel_key.data = fake_device
mock_sel.select.return_value = [(sel_key, None)]
from whisper_local.tray._hotkey_record_evdev import record_hotkey
record_hotkey(lambda n, c: results.append((n, c)), cancel)
assert results == [("KEY_F12", False)]
@@ -0,0 +1,71 @@
"""Hotkey-Aufzeichnung via evdev (Linux)."""
import selectors
import threading
from typing import Callable
import evdev
from evdev import InputDevice, ecodes
def find_all_keyboards() -> list[InputDevice]:
"""Gibt alle Input-Devices zurück, die EV_KEY-Events liefern können."""
keyboards: list[InputDevice] = []
for path in evdev.list_devices():
try:
device = InputDevice(path)
except (PermissionError, OSError):
continue
capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities:
keyboards.append(device)
else:
device.close()
return keyboards
def _keycode_to_name(code: int) -> str:
"""Übersetzt evdev-Keycode zu Key-Namen. Gibt '' bei unbekanntem Code."""
name = ecodes.KEY.get(code)
if isinstance(name, list):
return name[0]
if isinstance(name, str):
return name
return ""
def record_hotkey(
on_result: Callable[[str, bool], None],
cancel_event: threading.Event,
) -> None:
"""Blockiert bis zum ersten Keydown oder bis cancel_event gesetzt wird.
Ruft on_result(evdev_key_name, has_conflict) auf. has_conflict ist auf
Linux immer False — es gibt kein Äquivalent zum Win32-RegisterHotKey-Check.
"""
devices = find_all_keyboards()
if not devices:
return
selector = selectors.DefaultSelector()
try:
for dev in devices:
selector.register(dev.fd, selectors.EVENT_READ, dev)
captured: str | None = None
while captured is None and not cancel_event.is_set():
for key, _mask in selector.select(timeout=0.1):
dev: InputDevice = key.data
for event in dev.read():
if event.type == ecodes.EV_KEY and event.value == 1:
captured = _keycode_to_name(event.code)
break
if captured:
break
if captured and not cancel_event.is_set():
on_result(captured, False)
finally:
selector.close()
for dev in devices:
dev.close()