Files

931 lines
31 KiB
Markdown
Raw Permalink Normal View History

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