diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 9c21a48..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,94 +0,0 @@ -# Blitztext Linux Roadmap - -This roadmap tracks open work and near-term ideas for the Linux repo. -It is a planning note, not a promise. - -## Current scope - -- Linux desktop app for Ubuntu/Kubuntu on KDE Plasma and Wayland -- PyQt6 tray application with a real window fallback -- Global hotkeys via `evdev` (toggle and hold) -- Local transcription via `openai-whisper` and optional `faster-whisper` -- Optional OpenAI rewriting workflows -- Diktat, Verlauf, Vorlesen, Audio-Export, and notifications -- Install / verify / autostart flow driven by `scripts/install.sh` and `scripts/verify.sh` - -## Next useful work - -### Docs / repo hygiene - -- Keep the Linux README and `docs/` tree aligned with the actual app behavior -- Tighten CI coverage for repo-root docs and scripts -- Reduce stale local artifacts and improve repo hygiene -- Keep the installer and verify script in sync with the dependencies they check - -### Core product work - -- Add more regression coverage around startup, config, and transcription edge cases -- Improve the X11 fallback story or document the Wayland-only limitations more clearly -- Replace the current `evdev`/`input` global-hotkey path with a desktop-native XDG GlobalShortcuts integration when it is practical for KDE/Wayland -- Add a lightweight launch smoke test (boot the app offscreen and exit cleanly) so CI confirms the GUI actually starts, not just that mocked logic passes - -### Board ideas worth keeping - -- Harden `PasteService` with clipboard restore and a terminal-paste fallback -- Research streaming / VAD to lower dictation latency -- Research IBus / input-method integration as a long-term successor to the current paste flow -- Add a context mode that can suggest the right preset or tone before rewriting - -## Already shipped / not open roadmap items - -- Audio export from Read Aloud is already implemented -- Hold-to-talk is already available via the existing hotkey mode - -## Paket H — Audit hardening (2026-06-21) - -Derived from an external code audit, cross-verified by three independent -reviews (Claude, `security-reviewer`, Codex). Verdict: neither of the two -findings flagged "critical" is actually critical for this single-user, -no-network desktop app. The items below are real-but-lower-severity hardening -and quality improvements, ordered for step-by-step execution. - -Phase 1 — worthwhile hardening (recommended): - -- [x] H1 `config.py` `_load()`: split the broad `except Exception` (lines - ~115-117) into `json.JSONDecodeError` and `PermissionError`/`OSError`, - each logged with `exc_info=True` so failures are debuggable. (MEDIUM) -- [x] H2 `run.sh` secrets sourcing (lines ~24-30): add an ownership/permission - check before `source` (`[[ -O "$SECRETS_FILE" ]]` + warn if perms are - looser than 600); keep `source`. Do NOT use the audit's - `export $(grep ... | xargs)` fix — it corrupts keys with spaces/special - chars via word-splitting. (MEDIUM, defense-in-depth) - -Phase 2 — optional cleanups (LOW): - -- [x] H3 `transcribe.py` (line ~62): `Path(wav_file).resolve()` as - defense-in-depth (no real traversal vector exists; path is app-generated). -- [x] H4 `llm_service.py`: replace the `MagicMock` production fallback with a - small `_NullLLMClient` stub so `unittest.mock` is not on the prod path. -- [ ] H5 `hotkey_service.py` (line ~319): optional short `time.sleep` after the - inner `break` as cheap insurance (no real busy-loop — `select(...,1.0)` - and fd-pop already prevent CPU spin). Deferred intentionally: optional only, - no reproduced bug and no strong red test yet. - -Phase 3 — only if desired / verify first: - -- [x] H6 Make `PasteService` key delay configurable via config and wire it through app init/runtime updates. -- [x] H7 `transcribe._transcribe_openai`: avoid leaking `CUDA_VISIBLE_DEVICES` - mutations beyond the OpenAI Whisper call; local override restored after use. -- [x] H8 Collapse the duplicate default model source (`llm_service.MODEL` vs - `config.py` default) to a single source of truth. -- [x] H9 Verify (do not blindly remove) the `tts_openai_voice` validation set: - "marin"/"cedar" appear to be real newer OpenAI voices — the audit is - likely wrong here. Verified against the installed OpenAI client; no code - change needed. - -Explicitly out: treating finding 2 (path traversal) as critical; adopting the -audit's flawed `run.sh` xargs fix. - -## Not in scope - -- Hosted backend services -- App Store distribution -- macOS-first feature work -- Secrets management outside the local config file and user-controlled OpenAI account diff --git a/app/audio_recorder.py b/app/audio_recorder.py index a08b59c..cad826a 100644 --- a/app/audio_recorder.py +++ b/app/audio_recorder.py @@ -1,7 +1,6 @@ """AudioRecorder for BlitztextLinux. -Startet und stoppt Audioaufnahmen via parec (PulseAudio/PipeWire). -Extrahiert aus whisper-dictation scripts/dictate_toggle.py v0.2.19. +Starts and stops audio recordings via parec (PulseAudio/PipeWire). Design: - Synchron (kein Thread): der aufrufende Thread blockiert nicht, @@ -23,7 +22,7 @@ logger = logging.getLogger("blitztext.audio_recorder") -# Aufnahme-Parameter (identisch zu whisper-dictation) +# Recording parameters for Whisper-compatible 16 kHz mono WAV input. _RATE = 16000 _CHANNELS = 1 _LATENCY_MS = 100 @@ -102,7 +101,7 @@ def start(self, device: str = "@DEFAULT_SOURCE@") -> Path: "parec nicht gefunden. Bitte installieren: sudo apt install pulseaudio-utils" ) - # Stale-PID-Schutz (aus whisper-dictation v0.2.19) + # Stale PID guard for interrupted recording processes. self._cleanup_stale_pid() if self._pid_file.is_file(): diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 845fd76..e806aa8 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -31,7 +31,7 @@ sys.path.insert(0, PROJECT_DIR) from app.config import Config, DEFAULTS, VALID_HOTKEY_KEYS -from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, LLMServiceError +from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS from app.writing_presets import WRITING_PRESET_KEYS, get_preset, preset_index from app.hotkey_service import HotkeyWorker from app.audio_recorder import AudioRecorder, AudioRecorderError @@ -587,13 +587,10 @@ def run(self) -> None: if not transcript or not transcript.strip(): raise TranscribeError("Keine Sprache im Audio erkannt.") - # LLM rewrite if it is an LLM workflow + # LLM rewrite if it is an LLM workflow. rewrite() meldet einen + # fehlenden API-Key selbst als LLMServiceError mit Env-Var-Hinweis. if self.workflow in LLM_WORKFLOWS: self._emit("status_changed", "rewriting") - if not self.llm_service.is_available(): - raise LLMServiceError( - f"OpenAI API-Key nicht gesetzt. Bitte {self.config.openai_api_key_env} in ~/.config/blitztext-linux/secrets.env setzen." - ) result_text = self.llm_service.rewrite(self.workflow, transcript) else: result_text = transcript diff --git a/app/compose_window.py b/app/compose_window.py index 0da04a5..be583ef 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -655,7 +655,6 @@ def _start_worker(self, text: str) -> None: else: writing_preset = self._selected_preset() - thread = QThread(self) worker = _ComposeWorker( self._llm_service, workflow, @@ -664,6 +663,11 @@ def _start_worker(self, text: str) -> None: tone=tone, custom_prompt=custom_prompt, ) + self._launch_worker(worker) + + def _launch_worker(self, worker: _ComposeWorker) -> None: + """Gemeinsames Thread-Wiring für Standard- und Raw-Prompt-Worker.""" + thread = QThread(self) worker.moveToThread(thread) thread.started.connect(worker.run) worker.finished.connect(self._on_worker_result) @@ -739,7 +743,6 @@ def _on_show_prompt_clicked(self) -> None: self._start_worker_raw(dialog.get_system_prompt(), dialog.get_user_message()) def _start_worker_raw(self, system_prompt: str, user_message: str) -> None: - thread = QThread(self) worker = _ComposeWorker( self._llm_service, self._selected_workflow(), @@ -748,19 +751,7 @@ def _start_worker_raw(self, system_prompt: str, user_message: str) -> None: raw_system_prompt=system_prompt, raw_user_message=user_message, ) - worker.moveToThread(thread) - thread.started.connect(worker.run) - worker.finished.connect(self._on_worker_result) - worker.error.connect(self._on_worker_error) - worker.finished.connect(thread.quit) - worker.error.connect(thread.quit) - thread.finished.connect(worker.deleteLater) - thread.finished.connect(thread.deleteLater) - thread.finished.connect(self._on_worker_thread_finished) - self._worker_thread = thread - self._worker = worker - self._set_busy(True) - thread.start() + self._launch_worker(worker) @pyqtSlot(str) def _on_worker_result(self, result_text: str) -> None: diff --git a/app/history_panel.py b/app/history_panel.py index dbc25de..57cdaf6 100644 --- a/app/history_panel.py +++ b/app/history_panel.py @@ -1,7 +1,4 @@ -"""Verlauf-/Diktat-Panel fuer BlitztextLinux. - -Portiert und an die monolithische Blitztext-Architektur angepasst aus -whisper-dictation app/gui/history_panel.py. +"""History window for Blitztext Linux. GUI-freie Logik (Notiz speichern, Diktat zusammenfuehren) liegt in Modulfunktionen, damit sie ohne Qt/Display testbar ist. diff --git a/app/llm_service.py b/app/llm_service.py index 9c2df12..cb76bd4 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -176,18 +176,13 @@ def _rewrite_for_workflow( raise raise LLMServiceError(f"OpenAI API-Fehler: {exc}") from exc - def dampf_ablassen(self, transcript: str, custom_system_prompt: str = "") -> str: - self._check_openai() - if not transcript or not transcript.strip(): - raise ValueError("transcript must not be empty") - - system = (custom_system_prompt.strip() or self.dampf_system_prompt.strip() or _DAMPF_SYSTEM) + self._custom_terms_instruction() - + def _chat_completion(self, system: str, user: str) -> str: + """Führt den Chat-Completion-Aufruf aus; gemeinsamer Pfad aller Workflows.""" response = self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": system}, - {"role": "user", "content": transcript.strip()}, + {"role": "user", "content": user.strip()}, ], temperature=0.7, ) @@ -196,6 +191,14 @@ def dampf_ablassen(self, transcript: str, custom_system_prompt: str = "") -> str raise LLMServiceError("OpenAI hat eine leere Antwort zurückgegeben.") return content.strip() + def dampf_ablassen(self, transcript: str, custom_system_prompt: str = "") -> str: + self._check_openai() + if not transcript or not transcript.strip(): + raise ValueError("transcript must not be empty") + + system = (custom_system_prompt.strip() or self.dampf_system_prompt.strip() or _DAMPF_SYSTEM) + self._custom_terms_instruction() + return self._chat_completion(system, transcript) + def text_improver(self, transcript: str, tone: str = "neutral", custom_prompt: str = "") -> str: self._check_openai() if not transcript or not transcript.strip(): @@ -204,19 +207,7 @@ def text_improver(self, transcript: str, tone: str = "neutral", custom_prompt: s raise ValueError(f"invalid tone: {tone}") system = (custom_prompt.strip() or _TEXT_IMPROVER_SYSTEM_TEMPLATE.format(tone=tone)) + self._custom_terms_instruction() - - response = self.client.chat.completions.create( - model=self.model, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": transcript.strip()}, - ], - temperature=0.7, - ) - content = response.choices[0].message.content - if content is None: - raise LLMServiceError("OpenAI hat eine leere Antwort zurückgegeben.") - return content.strip() + return self._chat_completion(system, transcript) def emoji_text(self, transcript: str, density: str = "mittel") -> str: self._check_openai() @@ -226,19 +217,7 @@ def emoji_text(self, transcript: str, density: str = "mittel") -> str: raise ValueError(f"invalid density: {density}") system = _EMOJI_SYSTEM_TEMPLATE.format(density=density) + self._custom_terms_instruction() - - response = self.client.chat.completions.create( - model=self.model, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": transcript.strip()}, - ], - temperature=0.7, - ) - content = response.choices[0].message.content - if content is None: - raise LLMServiceError("OpenAI hat eine leere Antwort zurückgegeben.") - return content.strip() + return self._chat_completion(system, transcript) def rewrite(self, workflow: WorkflowType, transcript: str) -> str: """Send transcript to OpenAI and return the rewritten text. @@ -306,18 +285,7 @@ def rewrite_raw(self, system_prompt: str, user_message: str) -> str: if not user_message or not user_message.strip(): raise ValueError("user_message must not be empty") try: - response = self.client.chat.completions.create( - model=self.model, - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_message.strip()}, - ], - temperature=0.7, - ) - content = response.choices[0].message.content - if content is None: - raise LLMServiceError("OpenAI hat eine leere Antwort zurückgegeben.") - return content.strip() + return self._chat_completion(system_prompt, user_message) except Exception as exc: if isinstance(exc, LLMServiceError): raise diff --git a/app/notify.py b/app/notify.py index f246038..ff2f558 100644 --- a/app/notify.py +++ b/app/notify.py @@ -1,7 +1,7 @@ -"""Desktop-Benachrichtigungen via notify-send. +"""Desktop notifications for BlitztextLinux. -Portiert aus whisper-dictation app/gui/notify.py. Schlaegt nie hart fehl -- -fehlt notify-send, wird die Meldung still uebersprungen. +Notifications never fail hard. If notify-send is unavailable, the message is +silently skipped. """ from __future__ import annotations diff --git a/app/paste_service.py b/app/paste_service.py index 09df046..87e1b04 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -1,7 +1,5 @@ """PasteService for BlitztextLinux. -Kopiert/extrahiert aus whisper-dictation scripts/dictate_toggle.py v0.2.19. - Zwei Schritte: 1. wl-copy/xclip -- Text in Clipboard schreiben 2. ydotool -- Ctrl+V simulieren (nur wenn autopaste=True) @@ -20,9 +18,9 @@ logger = logging.getLogger("blitztext.paste_service") -# Verzoegerung zwischen wl-copy und ydotool key (identisch zu whisper-dictation) +# Delay between clipboard write and simulated paste key. _PASTE_DELAY = 0.15 -# ydotool key-delay in ms (identisch zu whisper-dictation) +# ydotool key-delay in ms. _KEY_DELAY_MS = 80 # Strg+V als rohe Keycodes (`:`). ydotool >=1.0 # interpretiert KEINE Tastennamen mehr wie "ctrl+v" -- solche Werte werden diff --git a/app/transcribe.py b/app/transcribe.py index 6dc8e55..220ab0a 100644 --- a/app/transcribe.py +++ b/app/transcribe.py @@ -1,13 +1,6 @@ #!/usr/bin/env python3 """Transcribe a WAV file with Whisper. -Kopiert aus whisper-dictation app/transcribe.py v0.2.19. -Aenderungen gegenueber Original: - - Logger-Name: blitztext.transcribe - - Neue oeffentliche Funktion transcribe() fuer direkten Python-Import - (BlitztextLinux ruft nicht per subprocess auf) - - main() und CLI-Interface bleiben unveraendert erhalten - Supports both openai-whisper and faster-whisper backends. """ from __future__ import annotations @@ -33,7 +26,7 @@ class TranscribeError(Exception): # --------------------------------------------------------------------------- -# Public API (neu gegenueber whisper-dictation) +# Public API # --------------------------------------------------------------------------- def transcribe( @@ -87,7 +80,7 @@ def transcribe( # --------------------------------------------------------------------------- -# Backend-Implementierungen (unveraendert aus whisper-dictation) +# Backend implementations # --------------------------------------------------------------------------- def _normalize_language(language: str) -> Optional[str]: @@ -203,7 +196,7 @@ def _transcribe_faster( # --------------------------------------------------------------------------- -# CLI (unveraendert aus whisper-dictation) +# CLI # --------------------------------------------------------------------------- def main() -> int: diff --git a/docs/setup.md b/docs/setup.md index 730db0e..50a2698 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -37,6 +37,22 @@ systemctl --user disable blitztext-linux ``` +## Desktop session notes + +BlitztextLinux is developed for KDE Plasma on Wayland, with X11 fallbacks where the +underlying tools support them. + +- GUI startup needs a real desktop session: either a usable `WAYLAND_DISPLAY` socket + or `DISPLAY` must be available. In headless shells, `scripts/verify.sh` can report + a warning even when the installed dependencies are otherwise correct. +- Qt prefers Wayland when `WAYLAND_DISPLAY` points to an existing socket. If that + variable is stale but `DISPLAY` is set, the launcher falls back to X11. +- Clipboard support uses `wl-copy`/`wl-paste` on Wayland and `xclip` on X11. +- Auto-paste uses `ydotool`; terminal windows may need `Ctrl+Shift+V` instead of + `Ctrl+V`, so the app detects known terminal window classes when possible. +- Global hotkeys still use `evdev`/the `input` group. A future desktop-native XDG + GlobalShortcuts integration would be a larger replacement, not a small setup fix. + ## Manual install If you want to debug the Linux setup path step by step: diff --git a/tests/test_hotkey_modes.py b/tests/test_hotkey_modes.py index 8e87b4a..645b263 100644 --- a/tests/test_hotkey_modes.py +++ b/tests/test_hotkey_modes.py @@ -415,6 +415,51 @@ class DeletedSignals: worker._emit("result", "text") +class TestTranscribeWorkerMissingApiKey: + def test_llm_workflow_without_key_reports_missing_key_message(self, tmp_path): + from app.blitztext_linux import _TranscribeWorker + from app.llm_service import LLMService + from app.workflows import WorkflowType + + wav = tmp_path / "audio.wav" + wav.write_bytes(b"RIFF") + + emitted: list[tuple[str, tuple]] = [] + + class _CaptureSignal: + def __init__(self, name: str) -> None: + self._name = name + + def emit(self, *args) -> None: + emitted.append((self._name, args)) + + class _CaptureSignals: + status_changed = _CaptureSignal("status_changed") + result = _CaptureSignal("result") + error = _CaptureSignal("error") + finished = _CaptureSignal("finished") + + worker = _TranscribeWorker( + wav_file=wav, + model="base", + language="de", + backend="openai-whisper", + workflow=WorkflowType.TEXT_IMPROVER, + llm_service=LLMService(api_key="", api_key_env="MY_TEST_KEY_ENV"), + autopaste=False, + paste_service=MagicMock(), + ) + worker.signals = _CaptureSignals() + + with patch("app.blitztext_linux.transcribe", return_value="hallo welt"): + worker.run() + + errors = [args[0] for name, args in emitted if name == "error"] + assert errors, "expected an error signal for the missing API key" + assert "MY_TEST_KEY_ENV" in errors[0] + assert "attribute" not in errors[0].lower() + + class TestModeFromConfig: def test_from_string_toggle(self, callbacks): svc = HotkeyService.from_config( diff --git a/tests/test_startup_environment.py b/tests/test_startup_environment.py new file mode 100644 index 0000000..33e2747 --- /dev/null +++ b/tests/test_startup_environment.py @@ -0,0 +1,70 @@ +"""Startup environment regression tests. + +These cover the display-environment decisions before QApplication is created. +They are intentionally headless: no real Qt app, evdev, audio, or clipboard tools. +""" +from __future__ import annotations + +import os + +import pytest + +from app.blitztext_linux import _configure_qt_platform, _require_display_environment + + +def test_require_display_environment_exits_when_no_display_is_available(monkeypatch, capsys): + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False) + + with pytest.raises(SystemExit) as excinfo: + _require_display_environment() + + assert excinfo.value.code == 1 + assert "No usable display environment" in capsys.readouterr().err + + +def test_require_display_environment_infers_wayland_socket_from_runtime_dir(tmp_path, monkeypatch): + socket_path = tmp_path / "wayland-1" + socket_path.touch() + # Track the variable with monkeypatch before the production helper mutates it, + # otherwise the inferred value can leak into later clipboard tests. + monkeypatch.setenv("WAYLAND_DISPLAY", "") + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path)) + + _require_display_environment() + + assert os.environ["WAYLAND_DISPLAY"] == "wayland-1" + + +def test_require_display_environment_falls_back_to_display_when_wayland_is_unusable(monkeypatch, capsys): + monkeypatch.setenv("WAYLAND_DISPLAY", "missing-wayland-socket") + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False) + + _require_display_environment() + + assert "WAYLAND_DISPLAY" not in os.environ + assert "falling back to DISPLAY" in capsys.readouterr().err + + +def test_configure_qt_platform_prefers_existing_wayland_socket(tmp_path, monkeypatch): + socket_path = tmp_path / "wayland-0" + socket_path.touch() + monkeypatch.delenv("QT_QPA_PLATFORM", raising=False) + monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path)) + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + + _configure_qt_platform() + + assert os.environ["QT_QPA_PLATFORM"] == "wayland" + + +def test_configure_qt_platform_respects_explicit_platform(monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + + _configure_qt_platform() + + assert os.environ["QT_QPA_PLATFORM"] == "offscreen"