# 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.