Files
whisper-local/docs/superpowers/plans/2026-04-11-linux-tray.md
T
2026-04-11 21:04:01 +02:00

931 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.