diff --git a/.gitignore b/.gitignore index 7a60b85..2940e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ __pycache__/ *.pyc +.env +.env.* +secrets.env diff --git a/README.md b/README.md index 842052a..b722a4d 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ systemctl --user enable --now ydotool.service # nutzt /usr/local/bin/ydotoold **6. Anwendung starten** ```bash -python app/blitztext_linux.py +./run.sh ``` @@ -144,7 +144,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast | **Blitztext :)** | Meta + Shift + E | ✅ | Ergänzt deine Nachricht passend mit Emojis. | > [!NOTE] -> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **OpenAI API-Key** voraus. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung. +> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **OpenAI API-Key** voraus. Lege ihn am einfachsten in `~/.config/blitztext-linux/secrets.env` ab, indem du dort die Variable `OPENAI_API_KEY` mit deinem Key als Wert setzt (Zeilenformat `NAME=WERT`). `./run.sh` und der systemd-Service laden diese Datei automatisch. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung. ## KI-Workflows @@ -244,7 +244,7 @@ Zusätzlich zu den Workflows bietet das Tool drei Komfort-Funktionen: ## Konfiguration -Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespeichert. +Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespeichert. Der OpenAI-Schlüssel wird nicht mehr in dieser Datei abgelegt, sondern aus einer Umgebungsvariable gelesen. Die Konfigurationsdatei lässt sich für erweiterte Prompt- und Workflow-Anpassungen direkt aus den Einstellungen öffnen: **Einstellungen → Allgemein → „Konfigurationsdatei öffnen"**.
Einstellungen Allgemein @@ -252,7 +252,7 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei
> [!IMPORTANT] -> Um den OpenAI API-Key zu schützen, wird die Datei automatisch mit restriktiven Dateiberechtigungen (**`0o600` / `chmod 600`**) gespeichert. +> Die Konfigurationsdatei wird automatisch mit restriktiven Dateiberechtigungen (**`0o600` / `chmod 600`**) gespeichert. Der echte OpenAI-Key liegt stattdessen in `~/.config/blitztext-linux/secrets.env` oder wird als Umgebungsvariable bereitgestellt.
Beispiel-Konfiguration & Felderklärung @@ -263,7 +263,7 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei "language": "de", "backend": "openai-whisper", "hotkey_mode": "toggle", - "openai_api_key": "DEIN_KEY", + "openai_api_key_env": "OPENAI_API_KEY", "autopaste": true, "audio_device": "@DEFAULT_SOURCE@", "workflows": { @@ -280,7 +280,8 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei - **hotkey_mode**: - `toggle`: Einmal drücken startet, erneutes Drücken beendet. - `hold`: Aufnahme läuft solange der Hotkey gedrückt wird. -- **openai_api_key**: OpenAI API-Key. +- **openai_api_key_env**: Name der Umgebungsvariable für den OpenAI API-Key. Standard: `OPENAI_API_KEY`. +- Der eigentliche Key liegt nicht in `config.json`, sondern in `~/.config/blitztext-linux/secrets.env` oder einer bereits gesetzten Umgebungsvariable. - **autopaste**: Fügt per `ydotool` ein. - **audio_device**: Name der Audioquelle. - **workflows**: Feintuning von Tonalität, Emojis und dem Dampf-Prompt. diff --git a/SECURITY.md b/SECURITY.md index 6a69f95..93c3225 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,7 +28,7 @@ Include: ## Security Notes - The app sends audio and text directly to OpenAI when you use the remote workflows. -- Your OpenAI API key is stored in `~/.config/blitztext-linux/config.json`, written with restrictive `0o600` permissions. +- Your OpenAI API key is read from the environment. Put it in `~/.config/blitztext-linux/secrets.env` (chmod `600`) or export the configured environment variable before launch. - Temporary audio files may exist briefly during processing. - Auto-paste uses `ydotool` to inject `Ctrl+V` into the focused application. - Global hotkeys read input from `/dev/input/event*` via `evdev`, which requires membership in the `input` group. On a shared session this means other processes of the same user could read input as well — a deliberate trade-off under Wayland without XDG GlobalShortcuts. Run Blitztext only in environments you trust. Replacing this path with a desktop-native XDG GlobalShortcuts integration is on the roadmap. diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 5ec1c28..a8f44cf 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -14,8 +14,10 @@ from pathlib import Path from typing import Optional -from PyQt6.QtCore import QObject, Qt, QThread, QThreadPool, QRunnable, pyqtSignal, pyqtSlot -from PyQt6.QtGui import QAction, QBrush, QColor, QIcon, QKeySequence, QPainter, QPen, QPixmap +from PyQt6.QtCore import QObject, Qt, QThread, QThreadPool, QRunnable, QUrl, pyqtSignal, pyqtSlot +from PyQt6.QtGui import ( + QAction, QBrush, QColor, QDesktopServices, QIcon, QKeySequence, QPainter, QPen, QPixmap, +) from PyQt6.QtWidgets import ( QApplication, QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QFormLayout, QComboBox, QLineEdit, QCheckBox, QPlainTextEdit, @@ -194,17 +196,28 @@ def init_ui(self) -> None: form_llm = QFormLayout(tab_llm) form_llm.setSpacing(10) - self.edit_api_key = QLineEdit() - self.edit_api_key.setText(self.config.openai_api_key) - self.edit_api_key.setEchoMode(QLineEdit.EchoMode.Password) - self.edit_api_key.setPlaceholderText("sk-...") + self.edit_api_key_env = QLineEdit() + self.edit_api_key_env.setText(self.config.openai_api_key_env) + self.edit_api_key_env.setPlaceholderText("OPENAI_API_KEY") + self.edit_api_key_env.textChanged.connect(lambda *_: self._refresh_api_key_status()) + + self.lbl_api_key_status = QLabel() + self.lbl_api_key_status.setWordWrap(True) - api_key_layout = QHBoxLayout() - api_key_layout.addWidget(self.edit_api_key) - self.btn_show_key = QPushButton("Anzeigen") - self.btn_show_key.setCheckable(True) - self.btn_show_key.clicked.connect(self._toggle_api_key_visibility) - api_key_layout.addWidget(self.btn_show_key) + api_key_layout = QVBoxLayout() + api_key_layout.addWidget(self.edit_api_key_env) + api_key_layout.addWidget(self.lbl_api_key_status) + if self.config.has_legacy_openai_api_key: + self.lbl_legacy_api_key_notice = QLabel( + "Legacy openai_api_key gefunden. Er wird beim nächsten Speichern entfernt." + ) + self.lbl_legacy_api_key_notice.setWordWrap(True) + self.lbl_legacy_api_key_notice.setStyleSheet("color: #b26a00; font-size: 10px;") + api_key_layout.addWidget(self.lbl_legacy_api_key_notice) + else: + self.lbl_legacy_api_key_notice = None + + self._refresh_api_key_status() self.combo_tone = QComboBox() self.combo_tone.addItems(["formal", "neutral", "locker"]) @@ -241,8 +254,8 @@ def init_ui(self) -> None: custom_terms_widget = QWidget() custom_terms_widget.setLayout(custom_terms_layout) - form_llm.addRow("OpenAI API-Key:", api_key_layout) - form_llm.addRow("", create_help_label("Erforderlich für alle KI/LLM-Features (Blitztext+, Dampf ablassen, Emojis).")) + form_llm.addRow("OpenAI API-Key-Umgebung:", api_key_layout) + form_llm.addRow("", create_help_label("Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen.")) form_llm.addRow("Text-Verbesserer Tonfall:", self.combo_tone) form_llm.addRow("Emoji-Dichte:", self.combo_emoji) @@ -276,6 +289,13 @@ def init_ui(self) -> None: form_general.addRow("Verlauf-Größe:", self.spin_history_size) form_general.addRow("", create_help_label("Maximale Anzahl der im Verlauf gespeicherten Einträge.")) + self.btn_open_config = QPushButton("📄 Konfigurationsdatei öffnen") + self.btn_open_config.clicked.connect(self._open_config_file) + form_general.addRow(self.btn_open_config) + form_general.addRow("", create_help_label( + "Öffnet config.json im Standard-Editor – für erweiterte Prompt- und " + "Workflow-Anpassungen, die über die Felder oben hinausgehen.")) + # Dezente Versionsanzeige ganz unten auf der letzten Einstellungsseite version_label = QLabel(f"Version {APP_VERSION}") version_label.setStyleSheet("color: gray; font-size: 9px;") @@ -292,13 +312,36 @@ def init_ui(self) -> None: button_box.rejected.connect(self.reject) layout.addWidget(button_box) - def _toggle_api_key_visibility(self) -> None: - if self.btn_show_key.isChecked(): - self.edit_api_key.setEchoMode(QLineEdit.EchoMode.Normal) - self.btn_show_key.setText("Verbergen") - else: - self.edit_api_key.setEchoMode(QLineEdit.EchoMode.Password) - self.btn_show_key.setText("Anzeigen") + def _refresh_api_key_status(self) -> None: + env_name = self.edit_api_key_env.text().strip() or self.config.openai_api_key_env + env_value = os.environ.get(env_name, "").strip() + status = "gesetzt" if env_value else "nicht gesetzt" + self.lbl_api_key_status.setText(f"Status: {status} ({env_name})") + + def _open_config_file(self) -> None: + """Open the config.json in the desktop's default editor. + + Falls die Datei noch nie gespeichert wurde oder noch ein Legacy-API-Key + im Speicher hängt, wird sie zuvor über die bestehende, atomare + ``config.save()``-Logik (0o600) angelegt bzw. bereinigt. + """ + try: + if (not self.config.config_file.is_file()) or self.config.has_legacy_openai_api_key: + self.config.save() + opened = QDesktopServices.openUrl( + QUrl.fromLocalFile(str(self.config.config_file))) + if not opened: + QMessageBox.warning( + self, + "Öffnen fehlgeschlagen", + f"Konfigurationsdatei konnte nicht geöffnet werden:\n{self.config.config_file}", + ) + except Exception as e: + QMessageBox.critical( + self, + "Fehler", + f"Konfigurationsdatei konnte nicht geöffnet werden: {e}", + ) def _collect_custom_terms(self) -> list[str]: terms: list[str] = [] @@ -338,7 +381,7 @@ def save_settings(self) -> None: self.config.hotkey_mode = self.combo_hotkey_mode.currentText() self.config.transcription_hotkey = self.combo_transcription_key.currentText() - self.config.openai_api_key = self.edit_api_key.text().strip() + self.config.openai_api_key_env = self.edit_api_key_env.text().strip() self.config.text_improver_tone = self.combo_tone.currentText() self.config.emoji_density = self.combo_emoji.currentText() self.config.dampf_system_prompt = self.edit_dampf_prompt.toPlainText().strip() @@ -414,7 +457,7 @@ def run(self) -> None: self._emit("status_changed", "rewriting") if not self.llm_service.is_available(): raise LLMServiceError( - "OpenAI API-Key nicht konfiguriert. Bitte in den Einstellungen eintragen." + 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: @@ -448,11 +491,12 @@ def __init__(self, app: QApplication) -> None: self.config = Config.load() self.llm_service = LLMService( - api_key=self.config.openai_api_key or "placeholder", + api_key=self.config.resolve_openai_api_key(), tone=self.config.text_improver_tone, emoji_density=self.config.emoji_density, dampf_system_prompt=self.config.dampf_system_prompt, custom_terms=self.config.custom_terms, + api_key_env=self.config.openai_api_key_env, ) self.audio_recorder = AudioRecorder() self.paste_service = PasteService(autopaste=self.config.autopaste) @@ -622,11 +666,12 @@ def show_settings_dialog(self) -> None: if dialog.exec() == QDialog.DialogCode.Accepted: # Update LLM Service parameters from saved configuration self.llm_service = LLMService( - api_key=self.config.openai_api_key or "placeholder", + api_key=self.config.resolve_openai_api_key(), tone=self.config.text_improver_tone, emoji_density=self.config.emoji_density, dampf_system_prompt=self.config.dampf_system_prompt, custom_terms=self.config.custom_terms, + api_key_env=self.config.openai_api_key_env, ) self.update_menu_availability() diff --git a/app/config.py b/app/config.py index 157a0d8..1828b78 100644 --- a/app/config.py +++ b/app/config.py @@ -1,13 +1,16 @@ """Config for BlitztextLinux. Pfad: ~/.config/blitztext-linux/config.json -Berechtigungen: 0o600 (API-Key liegt im File). +Berechtigungen: 0o600. Der eigentliche OpenAI-Key wird nur noch zur Laufzeit +über eine Umgebungsvariable gelesen. """ from __future__ import annotations +import copy import json import logging import os +import re from pathlib import Path from typing import Any @@ -19,7 +22,7 @@ "backend": "openai-whisper", "hotkey_mode": "hold", "transcription_hotkey": "KEY_LEFTALT", - "openai_api_key": "", + "openai_api_key_env": "OPENAI_API_KEY", "autopaste": True, "audio_device": "@DEFAULT_SOURCE@", "notes_folder": str(Path.home() / "Blitztext-Notizen"), @@ -44,6 +47,7 @@ "KEY_F13", "KEY_F14", "KEY_F15", "KEY_F16", "KEY_SCROLLLOCK", "KEY_PAUSE", "KEY_INSERT", "KEY_CAPSLOCK", } +ENV_VAR_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") class ConfigError(Exception): @@ -59,13 +63,14 @@ def __init__(self, config_dir: Path | None = None) -> None: else: self.config_dir = Path(config_dir) self.config_file = self.config_dir / "config.json" - - # Load data + self._legacy_openai_api_key_present = False + self._legacy_openai_api_key_value = "" + self._data = self._load() self._validate_and_sanitize() @classmethod - def load(cls, path: Path | None = None) -> BlitztextConfig: + def load(cls, path: Path | None = None) -> "BlitztextConfig": if path is not None: config_dir = Path(path).parent else: @@ -81,9 +86,14 @@ def _load(self) -> dict[str, Any]: data = json.loads(raw) if not isinstance(data, dict): return _deep_merge(DEFAULTS, {}) - return _deep_merge(DEFAULTS, data) - except Exception as exc: - # Avoid logging any loaded data to prevent leaking API keys + + legacy_api_key = data.get("openai_api_key") + self._legacy_openai_api_key_present = "openai_api_key" in data + self._legacy_openai_api_key_value = legacy_api_key.strip() if isinstance(legacy_api_key, str) else "" + sanitized = dict(data) + sanitized.pop("openai_api_key", None) + return _deep_merge(DEFAULTS, sanitized) + except Exception: logger.warning("Config could not be loaded, using defaults") return _deep_merge(DEFAULTS, {}) @@ -94,21 +104,40 @@ def save(self) -> None: tmp = self.config_file.with_suffix(".json.tmp") flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC fd = os.open(str(tmp), flags, 0o600) - with open(fd, "w", encoding="utf-8") as f: - json.dump(self._data, f, indent=2, ensure_ascii=False) + payload = _deep_copy_without_legacy_key(self._data) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, ensure_ascii=False) f.write("\n") tmp.replace(self.config_file) self.config_file.chmod(0o600) + self._data = payload + self._legacy_openai_api_key_present = False + self._legacy_openai_api_key_value = "" except OSError as exc: raise ConfigError(f"Config konnte nicht gespeichert werden: {exc}") from exc def has_api_key(self) -> bool: - key = self._data.get("openai_api_key", "") - return bool(key and key.strip()) + return bool(self.resolve_openai_api_key()) + + @property + def openai_api_key_env(self) -> str: + value = self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"]) + return _normalize_env_var_name(value) + + @openai_api_key_env.setter + def openai_api_key_env(self, value: str) -> None: + self._data["openai_api_key_env"] = _normalize_env_var_name(value) - # ------------------------------------------------------------------ - # Properties - # ------------------------------------------------------------------ + def resolve_openai_api_key(self) -> str: + env_name = self.openai_api_key_env + env_value = os.environ.get(env_name, "").strip() + if env_value: + return env_value + return self._legacy_openai_api_key_value + + @property + def has_legacy_openai_api_key(self) -> bool: + return self._legacy_openai_api_key_present @property def model(self) -> str: @@ -158,14 +187,6 @@ def transcription_hotkey(self, value: str) -> None: raise ValueError(f"Ungueltige Hotkey-Taste: {value!r}. Gueltig: {sorted(VALID_HOTKEY_KEYS)}") self._data["transcription_hotkey"] = value - @property - def openai_api_key(self) -> str: - return self._data["openai_api_key"] - - @openai_api_key.setter - def openai_api_key(self, value: str) -> None: - self._data["openai_api_key"] = value - @property def autopaste(self) -> bool: return bool(self._data["autopaste"]) @@ -218,7 +239,6 @@ def tts_speed(self, value: float) -> None: def workflows(self) -> dict[str, Any]: return self._data["workflows"] - # Convenience workflow sub-properties for the GUI @property def text_improver_tone(self) -> str: return self._data["workflows"]["text_improver_tone"] @@ -256,7 +276,6 @@ def custom_terms(self, value: list[str]) -> None: self._data["workflows"]["custom_terms"] = _sanitize_terms(value) def as_dict(self) -> dict[str, Any]: - import copy return copy.deepcopy(self._data) def _validate_and_sanitize(self) -> None: @@ -269,7 +288,6 @@ def _validate_and_sanitize(self) -> None: if self._data.get("transcription_hotkey") not in VALID_HOTKEY_KEYS: self._data["transcription_hotkey"] = "KEY_LEFTALT" - # History/TTS sanitize try: self._data["history_size"] = max(10, min(100, int(self._data.get("history_size", 50)))) except (TypeError, ValueError): @@ -283,13 +301,15 @@ def _validate_and_sanitize(self) -> None: if not isinstance(self._data.get("tts_voice", ""), str): self._data["tts_voice"] = "" - # Ensure workflows dict exists + self._data["openai_api_key_env"] = _normalize_env_var_name( + self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"]) + ) + self._data.pop("openai_api_key", None) + if "workflows" not in self._data or not isinstance(self._data["workflows"], dict): self._data["workflows"] = {} - + wf = self._data["workflows"] - - # Merge sub-defaults for k, v in DEFAULTS["workflows"].items(): if k not in wf: if isinstance(v, dict): @@ -306,10 +326,18 @@ def _validate_and_sanitize(self) -> None: wf["custom_terms"] = _sanitize_terms(wf.get("custom_terms")) +def _normalize_env_var_name(value: Any) -> str: + if not isinstance(value, str): + return DEFAULTS["openai_api_key_env"] + candidate = value.strip().upper() + if not candidate or not ENV_VAR_NAME_RE.fullmatch(candidate): + return DEFAULTS["openai_api_key_env"] + return candidate + + def _sanitize_terms(values: Any) -> list[str]: if not isinstance(values, list): return [] - result: list[str] = [] seen: set[str] = set() for value in values: @@ -323,9 +351,13 @@ def _sanitize_terms(values: Any) -> list[str]: return result -def _deep_merge(base: dict, override: dict) -> dict: - import copy +def _deep_copy_without_legacy_key(data: dict[str, Any]) -> dict[str, Any]: + payload = copy.deepcopy(data) + payload.pop("openai_api_key", None) + return payload + +def _deep_merge(base: dict, override: dict) -> dict: result = copy.deepcopy(base) for key, val in override.items(): if key in result and isinstance(result[key], dict) and isinstance(val, dict): @@ -336,4 +368,3 @@ def _deep_merge(base: dict, override: dict) -> dict: Config = BlitztextConfig - diff --git a/app/llm_service.py b/app/llm_service.py index ac63af2..58deb0a 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -3,6 +3,7 @@ import logging from typing import Any, Optional + from app.workflows import WorkflowType logger = logging.getLogger("blitztext.llm_service") @@ -10,7 +11,6 @@ LLM_WORKFLOWS = {WorkflowType.TEXT_IMPROVER, WorkflowType.DAMPF_ABLASSEN, WorkflowType.EMOJI_TEXT} MODEL = "gpt-4o-mini" -# --- System prompts --- _DAMPF_SYSTEM = ( "Du erhältst ein emotional gesprochenes Transkript. Erkenne zuerst das eigentliche " "Ziel, Anliegen und den wahren Frust der Person. Formuliere daraus eine klare, " @@ -50,42 +50,53 @@ def __init__( emoji_density: str = "mittel", dampf_system_prompt: str = "", custom_terms: Optional[list[str]] = None, + api_key_env: str = "OPENAI_API_KEY", ) -> None: - """ - Args: - api_key: OpenAI API key. Raises ValueError if empty/falsy. - client: Pre-built client (for testing/mocking). - tone: Text-improver tone: 'formal' | 'neutral' | 'locker'. - emoji_density: 'wenig' | 'mittel' | 'viel'. - dampf_system_prompt: Override for dampf_ablassen system prompt. - custom_terms: Globale Liste von Eigennamen/Fachbegriffen. - """ - if not api_key: - raise ValueError("api_key must not be empty") - - self.api_key = api_key + self.api_key = api_key or "" + self.api_key_env = api_key_env or "OPENAI_API_KEY" self.tone = tone self.emoji_density = emoji_density self.dampf_system_prompt = dampf_system_prompt self.custom_terms = self._sanitize_terms(custom_terms) self._openai_installed = True + self._client_is_fallback_mock = False if client is not None: self.client = client else: try: import openai - self.client = openai.OpenAI(api_key=api_key) except ImportError: self._openai_installed = False + self._client_is_fallback_mock = True from unittest.mock import MagicMock + self.client = MagicMock() + else: + if self.api_key and self.api_key.strip(): + self.client = openai.OpenAI(api_key=self.api_key) + else: + # Ohne API-Key keinen echten Client bauen: Neuere openai-Versionen + # werfen bereits im Konstruktor bei leerem Key. Der eigentliche + # Fehler wird zur Aufrufzeit über _check_openai() klar gemeldet, + # damit die App auch ohne gesetzten Key startet. + from unittest.mock import MagicMock + + self.client = MagicMock() def is_available(self) -> bool: return bool(self.api_key and self.api_key.strip()) + def _missing_key_message(self) -> str: + return ( + f"OpenAI API-Key nicht gesetzt. Bitte die Umgebungsvariable " + f"{self.api_key_env} in ~/.config/blitztext-linux/secrets.env setzen." + ) + def _check_openai(self) -> None: - if not self._openai_installed and type(self.client).__name__ != 'MagicMock': + if not self.is_available(): + raise LLMServiceError(self._missing_key_message()) + if not self._openai_installed and self._client_is_fallback_mock: raise LLMServiceError("openai-Paket nicht installiert. Bitte: pip install openai") @staticmethod @@ -117,9 +128,9 @@ 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() - + response = self.client.chat.completions.create( model=MODEL, messages=[ @@ -183,18 +194,18 @@ def rewrite(self, workflow: WorkflowType, transcript: str) -> str: Raises: LLMServiceError: If key is missing, package missing, or API error. """ + self._check_openai() if workflow not in LLM_WORKFLOWS: raise LLMServiceError(f"rewrite() only allowed for LLM workflows, got {workflow!r}") try: if workflow == WorkflowType.DAMPF_ABLASSEN: return self.dampf_ablassen(transcript, custom_system_prompt=self.dampf_system_prompt) - elif workflow == WorkflowType.TEXT_IMPROVER: + if workflow == WorkflowType.TEXT_IMPROVER: return self.text_improver(transcript, tone=self.tone) - elif workflow == WorkflowType.EMOJI_TEXT: + if workflow == WorkflowType.EMOJI_TEXT: return self.emoji_text(transcript, density=self.emoji_density) - else: - raise LLMServiceError(f"Unsupported workflow: {workflow}") + raise LLMServiceError(f"Unsupported workflow: {workflow}") except Exception as exc: if isinstance(exc, LLMServiceError): raise diff --git a/run.sh b/run.sh index e5a816d..8c62d77 100755 --- a/run.sh +++ b/run.sh @@ -5,6 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENV_PYTHON="${SCRIPT_DIR}/.venv/bin/python" LOCKFILE="${XDG_RUNTIME_DIR:-/tmp}/blitztext_linux.pid" +SECRETS_FILE="${HOME}/.config/blitztext-linux/secrets.env" # --- Single-Instance-Guard --- if [[ -f "${LOCKFILE}" ]]; then @@ -20,6 +21,14 @@ fi echo $$ > "${LOCKFILE}" trap 'rm -f "${LOCKFILE}"' EXIT INT TERM +# --- secrets.env (optional) --- +if [[ -f "${SECRETS_FILE}" ]]; then + set -a + # shellcheck disable=SC1090 + source "${SECRETS_FILE}" + set +a +fi + # --- venv-Prüfung --- if [[ ! -x "${VENV_PYTHON}" ]]; then echo "FEHLER: .venv nicht gefunden. Bitte zuerst 'bash scripts/install.sh' ausführen." >&2 diff --git a/systemd/blitztext-linux.service b/systemd/blitztext-linux.service index fce823e..b642137 100644 --- a/systemd/blitztext-linux.service +++ b/systemd/blitztext-linux.service @@ -5,6 +5,10 @@ # Das Installations-Skript ersetzt den Platzhalter %BLITZTEXT_DIR% automatisch # durch den absoluten Pfad des BlitztextLinux/-Verzeichnisses. # +# Manuelle Laufzeit-Konfiguration: +# Optionale Secrets liegen in ~/.config/blitztext-linux/secrets.env. +# Datei mit chmod 600 schützen; sie wird per EnvironmentFile geladen. +# # Manueller Start (nach install.sh und erfolgreichem Test): # systemctl --user start blitztext-linux # @@ -26,6 +30,7 @@ Type=simple # z. B. /home/benutzer/git/blitztext-linux WorkingDirectory=%BLITZTEXT_DIR% +EnvironmentFile=-%h/.config/blitztext-linux/secrets.env ExecStart=%BLITZTEXT_DIR%/.venv/bin/python app/blitztext_linux.py Restart=on-failure diff --git a/tests/test_config.py b/tests/test_config.py index cd2850d..7475247 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,161 +1,146 @@ -"""Tests für BlitztextConfig — Lesen, Schreiben, Defaults, API-Key-Handling.""" +"""Tests für BlitztextConfig — Defaults, Persistenz, Legacy-Migration.""" +from __future__ import annotations + import json -import os -import pytest from pathlib import Path + +import pytest + from app.config import BlitztextConfig @pytest.fixture -def config_dir(tmp_path): +def config_dir(tmp_path) -> Path: return tmp_path / ".config" / "blitztext-linux" @pytest.fixture -def config(config_dir): +def config(config_dir) -> BlitztextConfig: return BlitztextConfig(config_dir=config_dir) class TestDefaults: - def test_default_model(self, config): + def test_default_model_is_base(self, config): assert config.model == "base" - def test_default_language(self, config): + def test_default_language_is_de(self, config): assert config.language == "de" - def test_default_backend(self, config): + def test_default_backend_is_openai_whisper(self, config): assert config.backend == "openai-whisper" def test_default_hotkey_mode(self, config): assert config.hotkey_mode in ("toggle", "hold") - def test_default_api_key_empty(self, config): - assert config.openai_api_key == "" + def test_default_api_key_env(self, config): + assert config.openai_api_key_env == "OPENAI_API_KEY" + assert config.resolve_openai_api_key() == "" def test_default_autopaste(self, config): assert config.autopaste is True class TestPersistence: - def test_save_creates_file(self, config, config_dir): - config.save() - assert (config_dir / "config.json").exists() - def test_save_load_roundtrip(self, config, config_dir): config.model = "small" config.language = "en" + config.openai_api_key_env = "my_openai_key" config.save() + loaded = BlitztextConfig(config_dir=config_dir) assert loaded.model == "small" assert loaded.language == "en" + assert loaded.openai_api_key_env == "MY_OPENAI_KEY" def test_config_file_permissions(self, config, config_dir): config.save() - cfg_file = config_dir / "config.json" - mode = oct(cfg_file.stat().st_mode)[-3:] - assert mode == "600", f"Expected 600, got {mode}" + mode = (config_dir / "config.json").stat().st_mode & 0o777 + assert mode == 0o600 def test_partial_config_fills_defaults(self, config_dir): config_dir.mkdir(parents=True, exist_ok=True) - partial = {"model": "tiny"} - (config_dir / "config.json").write_text(json.dumps(partial)) + (config_dir / "config.json").write_text(json.dumps({"model": "tiny"}), encoding="utf-8") + loaded = BlitztextConfig(config_dir=config_dir) assert loaded.model == "tiny" - assert loaded.language == "de" # default + assert loaded.language == "de" + assert loaded.openai_api_key_env == "OPENAI_API_KEY" class TestWorkflowConfig: - def test_text_improver_tone_default(self, config): - assert config.workflows["text_improver_tone"] == "neutral" - - def test_emoji_density_default(self, config): - assert config.workflows["emoji_density"] == "mittel" - - def test_custom_dampf_prompt_default_empty(self, config): - assert config.workflows["dampf_system_prompt"] == "" - - def test_custom_terms_default_empty_list(self, config): - assert config.custom_terms == [] - - def test_custom_terms_persist_after_save_reload(self, config, config_dir): - config.custom_terms = ["Blitztext", "OpenRouter", "Leopoldshöhe"] - config.save() - loaded = BlitztextConfig(config_dir=config_dir) - assert loaded.custom_terms == ["Blitztext", "OpenRouter", "Leopoldshöhe"] - - def test_custom_terms_sanitized_on_setter(self, config): - config.custom_terms = [" Blitztext ", "", " ", "OpenRouter", "Blitztext", 5] - assert config.custom_terms == ["Blitztext", "OpenRouter"] - - def test_partial_workflow_config_without_custom_terms_remains_compatible(self, config_dir): + def test_workflows_dict_created_when_missing(self, config_dir): config_dir.mkdir(parents=True, exist_ok=True) - partial = { - "workflows": { - "text_improver_tone": "formal", - "emoji_density": "viel", - } - } - (config_dir / "config.json").write_text(json.dumps(partial), encoding="utf-8") + (config_dir / "config.json").write_text(json.dumps({"model": "base"}), encoding="utf-8") + loaded = BlitztextConfig(config_dir=config_dir) + assert isinstance(loaded.workflows, dict) + assert loaded.text_improver_tone == "neutral" + assert loaded.emoji_density == "mittel" assert loaded.custom_terms == [] - def test_custom_terms_sanitized_on_load(self, config_dir): - config_dir.mkdir(parents=True, exist_ok=True) - payload = { - "workflows": { - "custom_terms": [" Blitztext ", "", "OpenRouter", "Blitztext", None, 7, " "] - } - } - (config_dir / "config.json").write_text(json.dumps(payload), encoding="utf-8") - loaded = BlitztextConfig(config_dir=config_dir) - assert loaded.custom_terms == ["Blitztext", "OpenRouter"] + def test_custom_terms_are_sanitized(self, config): + config.custom_terms = [" Blitztext ", "Blitztext", "", "OpenRouter", 123] + assert config.custom_terms == ["Blitztext", "OpenRouter"] class TestTranscriptionHotkey: - def test_default_transcription_hotkey(self, config): - assert config.transcription_hotkey == "KEY_LEFTALT" - - def test_set_valid_transcription_hotkey(self, config): + def test_valid_hotkey_is_accepted(self, config): config.transcription_hotkey = "KEY_F13" assert config.transcription_hotkey == "KEY_F13" - def test_invalid_transcription_hotkey_raises(self, config): + def test_invalid_hotkey_is_rejected(self, config): with pytest.raises(ValueError): - config.transcription_hotkey = "KEY_SPACE" - - def test_transcription_hotkey_persists(self, config, config_dir): - config.transcription_hotkey = "KEY_RIGHTCTRL" - config.save() - loaded = BlitztextConfig(config_dir=config_dir) - assert loaded.transcription_hotkey == "KEY_RIGHTCTRL" - - def test_hotkey_mode_toggle_persists(self, config, config_dir): - config.hotkey_mode = "toggle" - config.save() - loaded = BlitztextConfig(config_dir=config_dir) - assert loaded.hotkey_mode == "toggle" - - def test_hotkey_mode_hold_persists(self, config, config_dir): - config.hotkey_mode = "hold" - config.save() - loaded = BlitztextConfig(config_dir=config_dir) - assert loaded.hotkey_mode == "hold" + config.transcription_hotkey = "KEY_A" class TestAPIKeyHandling: - def test_has_api_key_false_when_empty(self, config): - config.openai_api_key = "" + def test_has_api_key_false_when_env_missing(self, config, monkeypatch): + monkeypatch.delenv(config.openai_api_key_env, raising=False) assert config.has_api_key() is False - def test_has_api_key_true_when_set(self, config): - config.openai_api_key = "sk-abc123" + def test_has_api_key_true_when_env_set(self, config, monkeypatch): + monkeypatch.setenv(config.openai_api_key_env, "dummy-openai-key") assert config.has_api_key() is True + assert config.resolve_openai_api_key() == "dummy-openai-key" + + def test_invalid_env_var_name_is_normalized_to_default(self, config): + config.openai_api_key_env = " invalid-name " + assert config.openai_api_key_env == "OPENAI_API_KEY" - def test_api_key_not_logged(self, config, capsys, config_dir): - """API-Key darf nie in stdout/stderr landen.""" - config.openai_api_key = "sk-secret-do-not-log" + def test_legacy_api_key_is_removed_on_load_and_save(self, config_dir): + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text( + json.dumps({"openai_api_key": "legacy-placeholder", "model": "base"}), + encoding="utf-8", + ) + + loaded = BlitztextConfig(config_dir=config_dir) + assert loaded.has_legacy_openai_api_key is True + assert loaded.resolve_openai_api_key() == "legacy-placeholder" + assert loaded.has_api_key() is True + + loaded.save() + saved = json.loads((config_dir / "config.json").read_text(encoding="utf-8")) + assert "openai_api_key" not in saved + assert saved["openai_api_key_env"] == "OPENAI_API_KEY" + + def test_save_never_writes_openai_api_key(self, config_dir): + config = BlitztextConfig(config_dir=config_dir) + config.openai_api_key_env = "CUSTOM_OPENAI_KEY" config.save() + + saved = json.loads((config_dir / "config.json").read_text(encoding="utf-8")) + assert "openai_api_key" not in saved + assert saved["openai_api_key_env"] == "CUSTOM_OPENAI_KEY" + + + def test_env_value_wins_over_legacy_fallback(self, config_dir, monkeypatch): + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text( + json.dumps({"openai_api_key": "legacy-placeholder"}), + encoding="utf-8", + ) + + monkeypatch.setenv("OPENAI_API_KEY", "env-placeholder") loaded = BlitztextConfig(config_dir=config_dir) - captured = capsys.readouterr() - assert "sk-secret-do-not-log" not in captured.out - assert "sk-secret-do-not-log" not in captured.err + assert loaded.resolve_openai_api_key() == "env-placeholder" diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index aa1db3a..c3c50ef 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -1,143 +1,113 @@ -"""Tests für LLMService — OpenAI-Calls werden vollständig gemockt.""" -import pytest +"""Tests für LLMService — ohne echte Secrets oder Netzwerkzugriffe.""" +from __future__ import annotations + +from types import SimpleNamespace from unittest.mock import MagicMock, patch -from app.llm_service import LLMService + +import pytest + +from app.llm_service import LLMService, LLMServiceError +from app.workflows import WorkflowType -API_KEY = "***" +DUMMY_API_KEY = "dummy-openai-key" RAW_TRANSCRIPT = "Ich bin total genervt von diesem Projekt und alles ist kaputt!" CUSTOM_TERMS = ["Blitztext", "OpenRouter", "Leopoldshöhe"] @pytest.fixture -def service(): - return LLMService(api_key=API_KEY) +def mock_client(): + client = MagicMock() + response = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="OK"))]) + client.chat.completions.create.return_value = response + return client + + +@pytest.fixture +def service(mock_client): + return LLMService(api_key=DUMMY_API_KEY, client=mock_client) class TestLLMServiceInit: - def test_no_api_key_raises(self): - with pytest.raises(ValueError, match="api_key"): - LLMService(api_key="") + def test_empty_api_key_is_not_available(self, mock_client): + service = LLMService(api_key="", client=mock_client, api_key_env="CUSTOM_OPENAI_KEY") + assert service.is_available() is False + + def test_missing_key_message_mentions_env_name_without_value(self, mock_client): + service = LLMService(api_key="", client=mock_client, api_key_env="CUSTOM_OPENAI_KEY") - def test_api_key_stored(self, service): - assert service.api_key == API_KEY + with pytest.raises(LLMServiceError) as exc: + service.rewrite(WorkflowType.TEXT_IMPROVER, "test") - def test_custom_terms_are_stored(self): - service = LLMService(api_key=API_KEY, custom_terms=CUSTOM_TERMS) + message = str(exc.value) + assert "CUSTOM_OPENAI_KEY" in message + assert DUMMY_API_KEY not in message + assert "sk-" not in message + + def test_custom_terms_are_stored(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, custom_terms=CUSTOM_TERMS) assert service.custom_terms == CUSTOM_TERMS class TestDampfAblassen: def test_returns_string(self, service): - mock_response = MagicMock() - mock_response.choices[0].message.content = "Ich hätte gerne Feedback zum Projekt." - with patch.object(service.client.chat.completions, "create", return_value=mock_response): - result = service.dampf_ablassen(RAW_TRANSCRIPT) + result = service.dampf_ablassen(RAW_TRANSCRIPT) assert isinstance(result, str) - assert len(result) > 0 - - def test_passes_transcript_in_user_message(self, service): - mock_response = MagicMock() - mock_response.choices[0].message.content = "OK" - with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create: - service.dampf_ablassen(RAW_TRANSCRIPT) - call_kwargs = mock_create.call_args - messages = call_kwargs.kwargs.get("messages") or call_kwargs.args[0] + assert result == "OK" + + def test_passes_transcript_in_user_message(self, service, mock_client): + service.dampf_ablassen(RAW_TRANSCRIPT) + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] user_messages = [m for m in messages if m["role"] == "user"] assert any(RAW_TRANSCRIPT in m["content"] for m in user_messages) - def test_system_prompt_present(self, service): - mock_response = MagicMock() - mock_response.choices[0].message.content = "OK" - with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create: - service.dampf_ablassen(RAW_TRANSCRIPT) - messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0] - system_messages = [m for m in messages if m["role"] == "system"] - assert len(system_messages) >= 1 - - def test_system_prompt_contains_custom_terms_instruction(self): - service = LLMService(api_key=API_KEY, custom_terms=CUSTOM_TERMS) - mock_response = MagicMock() - mock_response.choices[0].message.content = "OK" - with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create: - service.dampf_ablassen(RAW_TRANSCRIPT) - messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0] + def test_system_prompt_contains_custom_terms_instruction(self, mock_client): + service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, custom_terms=CUSTOM_TERMS) + service.dampf_ablassen(RAW_TRANSCRIPT) + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] system_message = next(m["content"] for m in messages if m["role"] == "system") assert "muessen exakt so geschrieben werden" in system_message assert ", ".join(CUSTOM_TERMS) in system_message class TestTextImprover: - def test_neutral_tone(self, service): - mock_response = MagicMock() - mock_response.choices[0].message.content = "Sauberer Text." - with patch.object(service.client.chat.completions, "create", return_value=mock_response): - result = service.text_improver("roher text", tone="neutral") - assert isinstance(result, str) - def test_invalid_tone_raises(self, service): with pytest.raises(ValueError, match="tone"): service.text_improver("text", tone="aggressiv") - def test_custom_prompt_used(self, service): - mock_response = MagicMock() - mock_response.choices[0].message.content = "OK" + def test_custom_prompt_used(self, service, mock_client): custom = "Mein eigener Prompt:" - with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create: - service.text_improver("text", tone="neutral", custom_prompt=custom) - messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0] - all_content = " ".join(m["content"] for m in messages) - assert custom in all_content - - def test_prompt_contains_custom_terms_instruction(self): - service = LLMService(api_key=API_KEY, custom_terms=CUSTOM_TERMS) - mock_response = MagicMock() - mock_response.choices[0].message.content = "OK" - with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create: - service.text_improver("text", tone="neutral") - messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0] - system_message = next(m["content"] for m in messages if m["role"] == "system") - assert "muessen exakt so geschrieben werden" in system_message - assert ", ".join(CUSTOM_TERMS) in system_message - - def test_prompt_without_custom_terms_has_no_extra_instruction(self, service): - mock_response = MagicMock() - mock_response.choices[0].message.content = "OK" - with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create: - service.text_improver("text", tone="neutral") - messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0] - system_message = next(m["content"] for m in messages if m["role"] == "system") - assert "muessen exakt so geschrieben werden" not in system_message + service.text_improver("text", tone="neutral", custom_prompt=custom) + messages = mock_client.chat.completions.create.call_args.kwargs["messages"] + assert custom in " ".join(m["content"] for m in messages) class TestEmojiText: @pytest.mark.parametrize("density", ["wenig", "mittel", "viel"]) def test_valid_densities(self, service, density): - mock_response = MagicMock() - mock_response.choices[0].message.content = "Text mit Emojis 🎉" - with patch.object(service.client.chat.completions, "create", return_value=mock_response): - result = service.emoji_text("Hallo Welt", density=density) - assert isinstance(result, str) + result = service.emoji_text("Hallo Welt", density=density) + assert result == "OK" def test_invalid_density_raises(self, service): with pytest.raises(ValueError, match="density"): service.emoji_text("text", density="extrem") - def test_prompt_contains_custom_terms_instruction(self): - service = LLMService(api_key=API_KEY, custom_terms=CUSTOM_TERMS) - mock_response = MagicMock() - mock_response.choices[0].message.content = "Text mit Emojis 🎉" - with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create: - service.emoji_text("Hallo Welt", density="mittel") - messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0] - system_message = next(m["content"] for m in messages if m["role"] == "system") - assert "muessen exakt so geschrieben werden" in system_message - assert ", ".join(CUSTOM_TERMS) in system_message +class TestRewrite: + def test_rewrite_routes_to_text_improver(self, service): + with patch.object(service, "text_improver", return_value="verbessert") as patched: + result = service.rewrite(WorkflowType.TEXT_IMPROVER, "roh") + patched.assert_called_once_with("roh", tone=service.tone) + assert result == "verbessert" + + def test_openai_error_is_wrapped(self, service): + with patch.object(service, "dampf_ablassen", side_effect=RuntimeError("API Error")): + with pytest.raises(LLMServiceError, match="OpenAI API-Fehler: API Error"): + service.rewrite(WorkflowType.DAMPF_ABLASSEN, RAW_TRANSCRIPT) + + def test_missing_openai_package_raises_clear_error(self, service): + service._openai_installed = False + service._client_is_fallback_mock = True -class TestAPIError: - def test_openai_error_propagates(self, service): - """API-Fehler sollen nicht still geschluckt werden.""" - with patch.object(service.client.chat.completions, "create", side_effect=Exception("API Error")): - with pytest.raises(Exception, match="API Error"): - service.dampf_ablassen(RAW_TRANSCRIPT) + with pytest.raises(LLMServiceError, match="openai-Paket nicht installiert"): + service.rewrite(WorkflowType.TEXT_IMPROVER, "test") diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py new file mode 100644 index 0000000..e86c301 --- /dev/null +++ b/tests/test_settings_dialog.py @@ -0,0 +1,120 @@ +"""Tests für SettingsDialog-Helfer ohne echten Editor oder GUI-Leaks.""" +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import patch + +from app.blitztext_linux import SettingsDialog +from app.config import BlitztextConfig + + +def _fake_self(config_dir): + return SimpleNamespace(config=BlitztextConfig(config_dir=config_dir)) + + +def test_open_config_creates_missing_file(tmp_path): + fake = _fake_self(tmp_path / ".config" / "blitztext-linux") + assert not fake.config.config_file.is_file() + + with patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=True) as m_open, \ + patch("app.blitztext_linux.QMessageBox") as m_box: + SettingsDialog._open_config_file(fake) + + assert fake.config.config_file.is_file() + m_open.assert_called_once() + m_box.warning.assert_not_called() + m_box.critical.assert_not_called() + + +def test_open_config_passes_correct_local_path(tmp_path): + fake = _fake_self(tmp_path / ".config" / "blitztext-linux") + + with patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=True) as m_open, \ + patch("app.blitztext_linux.QMessageBox"): + SettingsDialog._open_config_file(fake) + + (url_arg,) = m_open.call_args.args + assert url_arg.toLocalFile() == str(fake.config.config_file) + + +def test_open_config_does_not_resave_existing_file_without_legacy_key(tmp_path): + fake = _fake_self(tmp_path / ".config" / "blitztext-linux") + fake.config.save() + + with patch.object(type(fake.config), "save") as m_save, \ + patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=True), \ + patch("app.blitztext_linux.QMessageBox"): + SettingsDialog._open_config_file(fake) + + m_save.assert_not_called() + + +def test_open_config_resaves_existing_legacy_config_before_open(tmp_path): + config_dir = tmp_path / ".config" / "blitztext-linux" + config_dir.mkdir(parents=True, exist_ok=True) + config_path = config_dir / "config.json" + config_path.write_text(json.dumps({"openai_api_key": "legacy-placeholder"}), encoding="utf-8") + fake = _fake_self(config_dir) + assert fake.config.has_legacy_openai_api_key is True + + with patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=True), \ + patch("app.blitztext_linux.QMessageBox"): + SettingsDialog._open_config_file(fake) + + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert "openai_api_key" not in saved + assert saved["openai_api_key_env"] == "OPENAI_API_KEY" + + +def test_open_config_handles_save_error_before_open(tmp_path): + fake = _fake_self(tmp_path / ".config" / "blitztext-linux") + + with patch.object(type(fake.config), "save", side_effect=OSError("disk full")), \ + patch("app.blitztext_linux.QDesktopServices.openUrl") as m_open, \ + patch("app.blitztext_linux.QMessageBox") as m_box: + SettingsDialog._open_config_file(fake) + + m_open.assert_not_called() + m_box.critical.assert_called_once() + m_box.warning.assert_not_called() + + +def test_open_config_handles_open_failure(tmp_path): + fake = _fake_self(tmp_path / ".config" / "blitztext-linux") + + with patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=False) as m_open, \ + patch("app.blitztext_linux.QMessageBox") as m_box: + SettingsDialog._open_config_file(fake) + + m_open.assert_called_once() + m_box.warning.assert_called_once() + m_box.critical.assert_not_called() + + +def test_refresh_api_key_status_shows_env_name_not_secret(monkeypatch): + secret_value = "dummy-openai-key" + monkeypatch.setenv("CUSTOM_OPENAI_KEY", secret_value) + + class FakeLineEdit: + def text(self): + return "CUSTOM_OPENAI_KEY" + + class FakeLabel: + def __init__(self): + self.text = "" + + def setText(self, value): + self.text = value + + fake = SimpleNamespace( + edit_api_key_env=FakeLineEdit(), + config=SimpleNamespace(openai_api_key_env="OPENAI_API_KEY"), + lbl_api_key_status=FakeLabel(), + ) + + SettingsDialog._refresh_api_key_status(fake) + + assert "CUSTOM_OPENAI_KEY" in fake.lbl_api_key_status.text + assert "gesetzt" in fake.lbl_api_key_status.text + assert secret_value not in fake.lbl_api_key_status.text