From 2793e7bd44b17783a5f3a89da3d8236a1f0cf507 Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Sat, 11 Apr 2026 21:04:01 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Implementation-Plan=20f=C3=BCr=20Linux-?= =?UTF-8?q?Tray=20&=20Settings-Dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-11-linux-tray.md | 930 ++++++++++++++++++ 1 file changed, 930 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-11-linux-tray.md diff --git a/docs/superpowers/plans/2026-04-11-linux-tray.md b/docs/superpowers/plans/2026-04-11-linux-tray.md new file mode 100644 index 0000000..5192b61 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-linux-tray.md @@ -0,0 +1,930 @@ +# Linux-Tray & Settings-Dialog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tray-Icon mit Einstellungs-Dialog für Linux/KDE/Wayland auf Basis des bestehenden Windows-Codes aktivieren. + +**Architecture:** Wiederverwendung von `pystray` + `tkinter` + `sv-ttk` (plattformunabhängiger Stack). Plattformspezifisch bleibt nur das Hotkey-Aufzeichnen (Linux: evdev, Windows: pynput) und der Konflikt-Check (nur Windows). + +**Tech Stack:** Python 3.13, pystray, tkinter, sv-ttk, darkdetect, Pillow, evdev, sounddevice + +**Spec:** [docs/superpowers/specs/2026-04-11-linux-tray-design.md](../specs/2026-04-11-linux-tray-design.md) + +--- + +## Dateistruktur + +| Datei | Rolle | +|---|---| +| `whisper_local/tray/__init__.py` | `create_tray()` dispatch (win32 + linux → PystrayApp) | +| `whisper_local/tray/_tray.py` | `AppState`, `PystrayApp` (umbenannt von Win32TrayApp), `NoOpTray` | +| `whisper_local/tray/_settings.py` | `SettingsDialog` + plattformübergreifende Helfer | +| `whisper_local/tray/_hotkey_record_pynput.py` | **Neu** — Windows-Record + Konflikt-Check (Extraktion) | +| `whisper_local/tray/_hotkey_record_evdev.py` | **Neu** — Linux-Record via evdev | +| `whisper_local/hotkey/_evdev.py` | `stop()`-Bugfix (Tasks canceln + Devices schließen) | +| `pyproject.toml` | `sys_platform == 'win32'`-Marker entfernen bei gemeinsamen Deps | +| `tests/test_tray.py` | Neue Tests für Linux-Record + Dispatch | +| `tests/test_hotkey.py` | Neuer Test für `stop()`-Fix | + +--- + +## Task 1: `Win32TrayApp` → `PystrayApp` umbenennen + +Struktureller Refactor ohne Verhaltensänderung. Macht den Klassennamen plattformneutral, damit Task 6 den Dispatch erweitern kann. + +**Files:** +- Modify: `whisper_local/tray/_tray.py` +- Modify: `whisper_local/tray/__init__.py` +- Modify: `tests/test_tray.py` + +- [ ] **Step 1: Rename Klasse in `_tray.py`** + +Ändere die Klasse in [whisper_local/tray/_tray.py:14](whisper_local/tray/_tray.py#L14): + +```python +class PystrayApp: + """Tray-Icon via pystray — cross-platform (Windows + Linux).""" + + def __init__(self, on_settings: Callable[[], None], on_quit: Callable[[], None]): + self._on_settings = on_settings + self._on_quit = on_quit + self._icon = None + + def start(self) -> None: + """Startet pystray in einem Daemon-Thread.""" + import pystray + from whisper_local.tray._icon import create_icon + + menu = pystray.Menu( + pystray.MenuItem("Einstellungen", self._menu_settings), + pystray.MenuItem("Beenden", self._menu_quit), + ) + self._icon = pystray.Icon( + "whisper-local", + create_icon(AppState.WAITING), + "whisper-local", + menu, + ) + thread = threading.Thread(target=self._icon.run, daemon=True) + thread.start() + + def set_state(self, state: AppState) -> None: + """Tauscht das Icon aus (thread-sicher).""" + if self._icon is not None: + from whisper_local.tray._icon import create_icon + self._icon.icon = create_icon(state) + + def _menu_settings(self, icon, item) -> None: + self._on_settings() + + def _menu_quit(self, icon, item) -> None: + if self._icon is not None: + self._icon.stop() + self._on_quit() +``` + +- [ ] **Step 2: `__init__.py` anpassen** + +Ersetze in [whisper_local/tray/__init__.py](whisper_local/tray/__init__.py) die Referenzen: + +```python +"""Tray-Package — plattformspezifische Tray-App.""" + +import sys +from typing import Callable + +from whisper_local.tray._tray import AppState, NoOpTray + + +def create_tray( + on_settings: Callable[[], None], + on_quit: Callable[[], None], +) -> "PystrayApp | NoOpTray": + """Gibt den plattformspezifischen Tray zurück.""" + if sys.platform == "win32": + from whisper_local.tray._tray import PystrayApp + return PystrayApp(on_settings=on_settings, on_quit=on_quit) + return NoOpTray() + + +__all__ = ["create_tray", "AppState"] +``` + +(Linux-Dispatch kommt in Task 6 — jetzt erstmal nur umbenennen.) + +- [ ] **Step 3: Tests anpassen** + +In `tests/test_tray.py` alle Vorkommen von `Win32TrayApp` durch `PystrayApp` ersetzen: + +- Zeile 28: `class TestWin32TrayApp:` → `class TestPystrayApp:` +- Zeile 31: `from whisper_local.tray._tray import AppState, Win32TrayApp` → `... PystrayApp` +- Zeile 33: `Win32TrayApp(...)` → `PystrayApp(...)` +- Zeile 44: dito +- Zeile 46: `Win32TrayApp(...)` → `PystrayApp(...)` +- Zeile 248-255 (`test_returns_win32_tray_on_windows`): Import und Assertion auf `PystrayApp` anpassen. Testname umbenennen zu `test_returns_pystray_on_windows`. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `uv run pytest tests/test_tray.py -v` +Expected: Alle bisher grünen Tests bleiben grün. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/tray/_tray.py whisper_local/tray/__init__.py tests/test_tray.py +git commit -m "refactor: rename Win32TrayApp to PystrayApp" +``` + +--- + +## Task 2: Windows-Hotkey-Record in eigenes Modul extrahieren + +Bewegt die Windows-spezifischen Record-/Konflikt-Funktionen aus `_settings.py` in ein neues Modul `_hotkey_record_pynput.py`. Verhaltenserhaltend, macht Platz für die Linux-Variante. + +**Files:** +- Create: `whisper_local/tray/_hotkey_record_pynput.py` +- Modify: `whisper_local/tray/_settings.py` +- Modify: `tests/test_tray.py` + +- [ ] **Step 1: Neues Modul anlegen** + +Erstelle `whisper_local/tray/_hotkey_record_pynput.py`: + +```python +"""Hotkey-Aufzeichnung und Konflikt-Erkennung für Windows (pynput + Win32).""" + +import ctypes +import threading +from typing import Callable + + +def pynput_to_evdev_key(key) -> str: + """Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12').""" + from pynput.keyboard import Key, KeyCode + + if isinstance(key, Key): + return f"KEY_{key.name.upper()}" + if isinstance(key, KeyCode) and key.char: + return f"KEY_{key.char.upper()}" + return "" + + +def check_hotkey_conflict(evdev_name: str) -> bool: + """Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist.""" + from whisper_local.hotkey._pynput import _evdev_to_pynput_key + from pynput.keyboard import Key + + try: + pynput_key = _evdev_to_pynput_key(evdev_name) + except ValueError: + return False + + if not isinstance(pynput_key, Key): + return False + + vk = getattr(pynput_key.value, "vk", None) + if vk is None: + return False + + HOTKEY_ID = 0x7FFF + user32 = ctypes.windll.user32 + if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk): + user32.UnregisterHotKey(None, HOTKEY_ID) + return False + return True + + +def record_hotkey( + on_result: Callable[[str, bool], None], + cancel_event: threading.Event, +) -> None: + """Blockiert bis der nächste Keydown kommt oder cancel_event gesetzt wird. + + Ruft on_result(evdev_key_name, has_conflict) auf. + """ + from pynput.keyboard import Listener + + captured: list[str] = [] + + def on_press(key): + evdev = pynput_to_evdev_key(key) + if evdev: + captured.append(evdev) + return False # Listener stoppen + + with Listener(on_press=on_press) as lst: + # Poll cancel_event while listener runs in separate thread + while lst.running and not cancel_event.is_set(): + lst.join(timeout=0.1) + if lst.running: + lst.stop() + + if captured and not cancel_event.is_set(): + evdev = captured[0] + on_result(evdev, check_hotkey_conflict(evdev)) +``` + +- [ ] **Step 2: `_settings.py` anpassen — Helper entfernen, Record-Logik per Dispatch** + +Ersetze [whisper_local/tray/_settings.py](whisper_local/tray/_settings.py) komplett mit: + +```python +"""Einstellungs-Dialog für whisper-local (cross-platform).""" + +from __future__ import annotations + +import sys +import threading +from typing import Callable + +import sounddevice as sd + +from whisper_local.config import Config, save_config +from whisper_local.tray._theme import apply_system_theme + + +def list_microphones() -> list[tuple[str, int]]: + """Gibt Liste aller Eingabegeräte als (name, index) zurück.""" + devices = sd.query_devices() + return [ + (dev["name"], idx) + for idx, dev in enumerate(devices) + if dev["max_input_channels"] > 0 + ] + + +def _get_record_hotkey(): + """Wählt das plattformspezifische record_hotkey-Callable.""" + if sys.platform == "win32": + from whisper_local.tray._hotkey_record_pynput import record_hotkey + return record_hotkey + from whisper_local.tray._hotkey_record_evdev import record_hotkey + return record_hotkey + + +class SettingsDialog: + """Einstellungs-Dialog (läuft in eigenem Thread).""" + + def __init__(self, config: Config, on_save: Callable[[Config], None]): + self._config = config + self._on_save = on_save + self._cancel_event = threading.Event() + + def open(self) -> None: + """Öffnet den Dialog in einem Daemon-Thread.""" + thread = threading.Thread(target=self._run, daemon=True) + thread.start() + + def _run(self) -> None: + import tkinter as tk + from tkinter import ttk + + root = tk.Tk() + root.title("whisper-local – Einstellungen") + root.resizable(False, False) + apply_system_theme(root) + + frame = ttk.Frame(root, padding=16) + frame.pack(fill=tk.BOTH, expand=True) + + # --- Hotkey --- + ttk.Label(frame, text="Hotkey").grid(row=0, column=0, sticky=tk.W, pady=4) + hotkey_var = tk.StringVar(value=self._config.hotkey) + ttk.Label(frame, textvariable=hotkey_var, width=14, relief="sunken").grid( + row=0, column=1, padx=8 + ) + + conflict_var = tk.StringVar() + ttk.Label(frame, textvariable=conflict_var, foreground="orange").grid( + row=1, column=0, columnspan=3, sticky=tk.W + ) + + record_hotkey = _get_record_hotkey() + + def do_record(): + hotkey_var.set("...") + conflict_var.set("") + + def on_result(evdev_key: str, has_conflict: bool): + root.after(0, lambda: hotkey_var.set(evdev_key)) + if has_conflict: + root.after( + 0, + lambda: conflict_var.set( + "⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)" + ), + ) + + def worker(): + record_hotkey(on_result, self._cancel_event) + + threading.Thread(target=worker, daemon=True).start() + + ttk.Button(frame, text="Aufzeichnen", command=do_record).grid( + row=0, column=2, padx=4 + ) + + # --- Mikrofon --- + ttk.Label(frame, text="Mikrofon").grid(row=2, column=0, sticky=tk.W, pady=4) + mics = list_microphones() + mic_names = ["Standard"] + [name for name, _ in mics] + current_mic = self._config.microphone or "Standard" + mic_var = tk.StringVar(value=current_mic) + ttk.Combobox( + frame, textvariable=mic_var, values=mic_names, state="readonly", width=32 + ).grid(row=2, column=1, columnspan=2, sticky=tk.W, padx=4) + + # --- Buttons --- + btn_frame = ttk.Frame(frame) + btn_frame.grid(row=3, column=0, columnspan=3, pady=12, sticky=tk.E) + + def save(): + new_config = Config( + hotkey=hotkey_var.get(), + whisper_model=self._config.whisper_model, + language=self._config.language, + compute_type=self._config.compute_type, + sample_rate=self._config.sample_rate, + channels=self._config.channels, + min_duration=self._config.min_duration, + microphone="" if mic_var.get() == "Standard" else mic_var.get(), + ) + save_config(new_config) + self._on_save(new_config) + self._cancel_event.set() + root.destroy() + + def cancel(): + self._cancel_event.set() + root.destroy() + + ttk.Button(btn_frame, text="Speichern", command=save).pack(side=tk.RIGHT, padx=4) + ttk.Button(btn_frame, text="Abbrechen", command=cancel).pack(side=tk.RIGHT) + + root.protocol("WM_DELETE_WINDOW", cancel) + root.mainloop() +``` + +- [ ] **Step 3: Tests umbiegen** + +In `tests/test_tray.py`: + +1. `TestCheckHotkeyConflict`: Import ändern von `whisper_local.tray._settings` auf `whisper_local.tray._hotkey_record_pynput`. +2. `TestPynputToEvdevKey`: Import ändern von `whisper_local.tray._settings` auf `whisper_local.tray._hotkey_record_pynput`. +3. `TestListMicrophones`: bleibt (liegt noch in `_settings`). +4. `TestSettingsDialog.test_on_save_callback_called_when_save_invoked`: Der Patch `patch("whisper_local.tray._settings.apply_system_theme")` bleibt, aber die Referenz auf `pynput_to_evdev_key` etc. existiert nicht mehr in `_settings`. Prüfe, dass dieser Test noch läuft ohne Referenzen auf ausgelagerte Funktionen. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `uv run pytest tests/test_tray.py -v` +Expected: Alle bisher grünen Tests bleiben grün. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/tray/_hotkey_record_pynput.py whisper_local/tray/_settings.py tests/test_tray.py +git commit -m "refactor: Windows-Hotkey-Record in eigenes Modul auslagern" +``` + +--- + +## Task 3: Linux-Hotkey-Record implementieren (TDD) + +Neue Funktion `record_hotkey()` auf Basis von `evdev` + `selectors`. Blockiert bis zum ersten Keydown oder Cancel. + +**Files:** +- Create: `whisper_local/tray/_hotkey_record_evdev.py` +- Modify: `tests/test_tray.py` + +- [ ] **Step 1: Failing Test schreiben** + +Füge in `tests/test_tray.py` am Ende ein: + +```python +@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 evdev-Device mit einem Keydown-Event + fake_event = MagicMock() + fake_event.type = 1 # EV_KEY + fake_event.code = 88 # KEY_F12 (ecodes.KEY_F12 == 88) + 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], + ): + 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([]) # keine Events + fake_device.close = MagicMock() + + cancel = threading.Event() + cancel.set() # sofort abbrechen + + results: list[tuple[str, bool]] = [] + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ): + 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 # EV_KEY + key_up.code = 88 + key_up.value = 0 # Key-Up + + 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], + ): + 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)] +``` + +- [ ] **Step 2: Test ausführen — erwartet FAIL** + +Run: `uv run pytest tests/test_tray.py::TestRecordHotkeyEvdev -v` +Expected: `ModuleNotFoundError: No module named 'whisper_local.tray._hotkey_record_evdev'` + +- [ ] **Step 3: Modul `_hotkey_record_evdev.py` implementieren** + +Erstelle `whisper_local/tray/_hotkey_record_evdev.py`: + +```python +"""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() +``` + +- [ ] **Step 4: Tests ausführen — erwartet PASS** + +Run: `uv run pytest tests/test_tray.py::TestRecordHotkeyEvdev -v` +Expected: Alle drei Tests grün. + +Falls `fake_device.read()` nicht wie erwartet funktioniert, weil der Selector `selector.select()` blockt: Die Test-Strategie muss den Selector mocken. Alternative: + +```python +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 + # select() liefert den Device-Key zurück + sel_key = MagicMock() + sel_key.data = fake_device + mock_sel.select.return_value = [(sel_key, None)] + ... +``` + +Falls der erste Testlauf scheitert, ziehe den Selector-Mock nach. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/tray/_hotkey_record_evdev.py tests/test_tray.py +git commit -m "feat: Linux-Hotkey-Record via evdev" +``` + +--- + +## Task 4: `EvdevHotkeyListener.stop()` reparieren (TDD) + +Bestehender Bug: `stop()` cancelt die Read-Tasks nicht und schließt die Devices nicht. Das macht den Config-Reload nach Einstellungsänderung kaputt. + +**Files:** +- Modify: `whisper_local/hotkey/_evdev.py` +- Modify: `tests/test_hotkey.py` + +- [ ] **Step 1: Failing Test schreiben** + +Füge in `tests/test_hotkey.py` am Ende ein: + +```python +@pytest.mark.skipif(sys.platform != "linux", reason="evdev nur auf Linux") +class TestEvdevListenerStop: + @pytest.mark.asyncio + async def test_stop_cancels_tasks_and_closes_devices(self): + from unittest.mock import MagicMock, patch + from whisper_local.hotkey._evdev import EvdevHotkeyListener + + listener = EvdevHotkeyListener("KEY_F12") + + async def never_ending(device): + while True: + await asyncio.sleep(1) + + fake_device = MagicMock() + fake_device.close = MagicMock() + + with patch( + "whisper_local.hotkey._evdev.find_keyboard_devices", + return_value=[fake_device], + ), patch.object(listener, "_read_device", side_effect=never_ending): + listen_task = asyncio.create_task(listener.listen()) + await asyncio.sleep(0.05) # Loop-Schritt, damit listen() startet + + assert len(listener._tasks) == 1 + listener.stop() + await asyncio.sleep(0.05) + + assert listener._tasks == [] + fake_device.close.assert_called_once() + + # listen() sollte sauber zurückkehren + await asyncio.wait_for(listen_task, timeout=1.0) +``` + +- [ ] **Step 2: Test ausführen — erwartet FAIL** + +Run: `uv run pytest tests/test_hotkey.py::TestEvdevListenerStop -v` +Expected: FAIL — `listener._tasks` existiert nicht. + +- [ ] **Step 3: Listener patchen** + +Ersetze [whisper_local/hotkey/_evdev.py](whisper_local/hotkey/_evdev.py) komplett mit: + +```python +"""Hotkey-Listener via evdev für Push-to-Talk (Linux).""" + +import asyncio +import logging + +import evdev +from evdev import InputDevice, categorize, ecodes + +from whisper_local.hotkey import AsyncCallback + +logger = logging.getLogger(__name__) + + +def find_keyboard_devices(key_name: str) -> list[InputDevice]: + """Findet alle Devices die den angegebenen Key unterstützen.""" + matches = [] + for path in evdev.list_devices(): + device = InputDevice(path) + capabilities = device.capabilities(verbose=True) + for (etype_name, _etype_code), events in capabilities.items(): + if etype_name == "EV_KEY": + key_names = [name for name, _code in events] + if key_name in key_names: + logger.info("Device mit %s gefunden: %s (%s)", key_name, device.name, device.path) + matches.append(device) + break + if not matches: + raise RuntimeError(f"Kein Device mit {key_name} gefunden in /dev/input/") + return matches + + +class EvdevHotkeyListener: + def __init__(self, key_name: str = "KEY_F12"): + self.key_name = key_name + self.key_code = ecodes.ecodes.get(key_name) + if self.key_code is None: + raise ValueError(f"Unbekannter Key-Name: {key_name}") + self.on_press: AsyncCallback | None = None + self.on_release: AsyncCallback | None = None + self._tasks: list[asyncio.Task] = [] + self._devices: list[InputDevice] = [] + + async def _handle_key_event(self, key_down: bool) -> None: + """Ruft den passenden Callback auf.""" + if key_down and self.on_press: + await self.on_press() + elif not key_down and self.on_release: + await self.on_release() + + async def _read_device(self, device: InputDevice) -> None: + """Liest Events von einem einzelnen Device.""" + async for event in device.async_read_loop(): + if event.type == ecodes.EV_KEY and event.code == self.key_code: + if event.value == 1: + logger.debug("%s gedrückt (via %s)", self.key_name, device.path) + await self._handle_key_event(key_down=True) + elif event.value == 0: + logger.debug("%s losgelassen (via %s)", self.key_name, device.path) + await self._handle_key_event(key_down=False) + + def stop(self) -> None: + """Cancelt laufende Read-Tasks und schließt Devices.""" + for task in self._tasks: + task.cancel() + for dev in self._devices: + try: + dev.close() + except Exception: + pass + self._tasks = [] + self._devices = [] + + async def listen(self) -> None: + """Lauscht auf evdev-Events der konfigurierten Taste auf allen passenden Devices.""" + self._devices = find_keyboard_devices(self.key_name) + for dev in self._devices: + logger.info("Lausche auf %s auf %s (%s)", self.key_name, dev.name, dev.path) + self._tasks = [asyncio.create_task(self._read_device(dev)) for dev in self._devices] + await asyncio.gather(*self._tasks, return_exceptions=True) +``` + +- [ ] **Step 4: Tests ausführen — erwartet PASS** + +Run: `uv run pytest tests/test_hotkey.py -v` +Expected: Alle Linux-Tests grün. Die bestehenden Tests (`test_init_stores_key_name`, `test_key_down_calls_callback`, ...) bleiben unverändert grün. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/hotkey/_evdev.py tests/test_hotkey.py +git commit -m "fix: EvdevHotkeyListener.stop() cancelt Tasks und schließt Devices" +``` + +--- + +## Task 5: `create_tray()`-Dispatch für Linux erweitern + +**Files:** +- Modify: `whisper_local/tray/__init__.py` +- Modify: `tests/test_tray.py` + +- [ ] **Step 1: Failing Test schreiben** + +Ersetze in `tests/test_tray.py` den bisherigen `test_returns_noop_tray_on_non_windows` (Zeile 257-264) und füge einen Linux-Test hinzu: + +```python +class TestCreateTray: + @pytest.mark.skipif(sys.platform != "win32", reason="Win32 nur auf Windows") + def test_returns_pystray_on_windows(self): + from unittest.mock import MagicMock + from whisper_local.tray import create_tray + from whisper_local.tray._tray import PystrayApp + + tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock()) + assert isinstance(tray, PystrayApp) + + @pytest.mark.skipif(sys.platform != "linux", reason="Linux-Tray nur auf Linux") + def test_returns_pystray_on_linux(self): + from unittest.mock import MagicMock + from whisper_local.tray import create_tray + from whisper_local.tray._tray import PystrayApp + + tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock()) + assert isinstance(tray, PystrayApp) + + def test_appstate_exported_from_package(self): + from whisper_local.tray import AppState + assert AppState.WAITING is not None + assert AppState.RECORDING is not None + assert AppState.TRANSCRIBING is not None +``` + +- [ ] **Step 2: Test ausführen — erwartet FAIL** + +Run: `uv run pytest tests/test_tray.py::TestCreateTray::test_returns_pystray_on_linux -v` +Expected: FAIL — `create_tray()` liefert auf Linux noch `NoOpTray`. + +- [ ] **Step 3: Dispatch erweitern** + +Ersetze [whisper_local/tray/__init__.py](whisper_local/tray/__init__.py): + +```python +"""Tray-Package — plattformspezifische Tray-App.""" + +import sys +from typing import Callable + +from whisper_local.tray._tray import AppState, NoOpTray + + +def create_tray( + on_settings: Callable[[], None], + on_quit: Callable[[], None], +) -> "PystrayApp | NoOpTray": + """Gibt den plattformspezifischen Tray zurück.""" + if sys.platform in ("win32", "linux"): + from whisper_local.tray._tray import PystrayApp + return PystrayApp(on_settings=on_settings, on_quit=on_quit) + return NoOpTray() + + +__all__ = ["create_tray", "AppState"] +``` + +- [ ] **Step 4: Tests ausführen — erwartet PASS** + +Run: `uv run pytest tests/test_tray.py -v` +Expected: Alle Tests grün. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/tray/__init__.py tests/test_tray.py +git commit -m "feat: create_tray() dispatcht auf Linux zu PystrayApp" +``` + +--- + +## Task 6: `pyproject.toml` — Dependencies für Linux freigeben + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Marker entfernen** + +Ersetze die Dependencies-Liste in [pyproject.toml:5-16](pyproject.toml#L5-L16): + +```toml +dependencies = [ + "faster-whisper>=1.1.0", + "sounddevice>=0.5.0", + "numpy>=2.0.0", + "evdev>=1.7.0; sys_platform == 'linux'", + "pynput>=1.7.0; sys_platform == 'win32'", + "pywin32>=306; sys_platform == 'win32'", + "pystray>=0.19.0", + "Pillow>=10.0.0", + "sv-ttk>=2.6.0", + "darkdetect>=0.8.0", +] +``` + +- [ ] **Step 2: `uv sync` ausführen** + +Run: `uv sync` +Expected: Lock-File wird aktualisiert, alle Pakete installieren sauber. + +- [ ] **Step 3: Smoke-Import** + +Run: `uv run python -c "import pystray, PIL, sv_ttk, darkdetect; print('ok')"` +Expected: `ok` ohne Traceback. + +Hinweis: Falls `pystray` auf dem System einen Backend-Fehler wirft (fehlendes `libayatana-appindicator`), installiere `libayatana-appindicator` via Paketmanager. Der Import selbst sollte aber funktionieren — der AppIndicator wird erst zur Laufzeit beim `icon.run()` benötigt. + +- [ ] **Step 4: Gesamte Test-Suite** + +Run: `uv run pytest -v` +Expected: Alle Tests grün. + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "build: pystray/Pillow/sv-ttk/darkdetect für Linux freigeben" +``` + +--- + +## Task 7: Manueller Smoke-Test + +Kein Code-Change — verifiziert, dass App unter Linux tatsächlich startet, Tray sichtbar ist und Settings-Dialog funktioniert. + +- [ ] **Step 1: App starten** + +Run: `uv run whisper-local` +Expected: App startet, Log zeigt `whisper-local gestartet, warte auf Hotkey...`, Tray-Icon (graues Mikrofon) erscheint in der KDE-Taskleiste. + +- [ ] **Step 2: Hotkey-Roundtrip** + +Drücke `F12` (oder den konfigurierten Hotkey) kurz und sprich etwas. Lasse los. +Expected: Tray-Icon wird rot (Recording), dann gelb (Transcribing), dann wieder grau. Transkribierter Text wird eingefügt. + +- [ ] **Step 3: Settings-Dialog** + +Rechtsklick auf Tray → „Einstellungen". Klicke „Aufzeichnen" und drücke eine beliebige Taste. +Expected: Der neue Key erscheint im Label. Klicke „Speichern". Dialog schließt, App läuft weiter, neuer Hotkey funktioniert. + +- [ ] **Step 4: Beenden** + +Rechtsklick auf Tray → „Beenden". +Expected: App beendet sich sauber, kein Zombie-Prozess (`pgrep -af whisper-local` liefert nichts). + +- [ ] **Step 5: Ergebnis dokumentieren** + +Falls alle Schritte funktionieren: Feature ist fertig. Falls nicht, Befund notieren und als neuen Task aufnehmen. + +--- + +## Selbst-Review-Hinweise für den ausführenden Agent + +- **Backend-Check auf CachyOS:** Falls pystray beim `icon.run()` einen Fehler wie `ValueError: Unable to find a tray icon backend` wirft, fehlt AppIndicator. Installiere: `sudo pacman -S libayatana-appindicator` +- **evdev-Permissions:** Wenn `find_all_keyboards()` keine Devices zurückliefert, prüfe Gruppenmitgliedschaft: `groups | grep input`. Nutzer muss in Gruppe `input` sein. +- **Task 3 Test-Strategie:** Wenn der naive Mock-Ansatz für `selectors.DefaultSelector` nicht reicht, nutze den Selector-Patch aus Task 3 Step 4. +- **Keine Änderung an `__main__.py` nötig** — die `create_tray()`-API bleibt gleich, und der bestehende `_restart_hotkey()` funktioniert mit dem `stop()`-Fix aus Task 4 korrekt auf Linux.