Files
2026-04-11 21:04:01 +02:00

31 KiB
Raw Permalink Blame 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


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: Win32TrayAppPystrayApp 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:

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 die Referenzen:

"""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
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:

"""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 komplett mit:

"""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
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:

@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:

"""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:

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
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:

@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 komplett mit:

"""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
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:

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:

"""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
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:

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