From f8fe8a933c936171890dd41a9385f11ec5d2e30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 13:22:20 +0200 Subject: [PATCH 1/6] fix: sanitize external LLM error messages --- app/blitztext_linux.py | 8 +++++--- app/llm_service.py | 30 ++++++++++++++++++++++++++++++ tests/test_llm_service.py | 22 +++++++++++++++++++++- tests/test_state_machine.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 4cca559..d2253f5 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 +from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, sanitize_external_error from app.writing_presets import ( CUSTOM_PRESET_KEY, WRITING_PRESET_KEYS, @@ -1145,8 +1145,10 @@ def _on_no_speech(self) -> None: @pyqtSlot(str) def _on_worker_error(self, err_msg: str) -> None: - logger.error("Worker error: %s", err_msg) - self._finish_worker_with_error(err_msg, "worker error") + logger.debug("Raw worker error: %s", err_msg) + safe_error = sanitize_external_error(err_msg) + logger.error("Worker error: %s", safe_error) + self._finish_worker_with_error(safe_error, "worker error") def _finish_worker_with_error(self, err_msg: str, reason: str) -> None: self.show_tray_error(t("notify.error.title"), err_msg) diff --git a/app/llm_service.py b/app/llm_service.py index c6565d1..5a71b92 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -2,7 +2,9 @@ from __future__ import annotations import logging +import re from typing import Any, Optional +import unicodedata from app.config import DEFAULTS from app.workflows import WorkflowType @@ -19,6 +21,16 @@ LLM_WORKFLOWS = {WorkflowType.TEXT_IMPROVER, WorkflowType.DAMPF_ABLASSEN, WorkflowType.EMOJI_TEXT} DEFAULT_LLM_MODEL = DEFAULTS["llm_model"] +_REDACTED = "[REDACTED]" +_URL_USERINFO_PATTERN = re.compile(r"(https?://)[^\s/@:]+:[^\s/@]+@", re.IGNORECASE) +_BEARER_TOKEN_PATTERN = re.compile(r"\bBearer\s+[^\s,;]+", re.IGNORECASE) +_SK_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]+") +_NAMED_SECRET_PATTERN = re.compile( + r"\b(api_key|apikey|token|secret|password)\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)", + re.IGNORECASE, +) +_EMPTY_EXTERNAL_ERROR = "Unbekannter Fehler" + _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, " @@ -61,6 +73,24 @@ ) +def sanitize_external_error(message: object, max_length: int = 240) -> str: + """Return a safe, compact representation of an external error message.""" + text = str(message) + text = _URL_USERINFO_PATTERN.sub(rf"\1{_REDACTED}@", text) + text = _BEARER_TOKEN_PATTERN.sub(f"Bearer {_REDACTED}", text) + text = _SK_KEY_PATTERN.sub(_REDACTED, text) + text = _NAMED_SECRET_PATTERN.sub(lambda match: f"{match.group(1)}={_REDACTED}", text) + text = "".join( + " " if unicodedata.category(character).startswith("C") else character + for character in text + ) + text = " ".join(text.split()) or _EMPTY_EXTERNAL_ERROR + limit = max(1, int(max_length)) + if len(text) <= limit: + return text + return text[: limit - 1] + "…" + + class LLMServiceError(Exception): """Raised when an LLM call fails.""" diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 57dafc1..b080777 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -7,7 +7,7 @@ import pytest from app.config import DEFAULTS -from app.llm_service import LLMService, LLMServiceError, _NullLLMClient +from app.llm_service import LLMService, LLMServiceError, _NullLLMClient, sanitize_external_error from app.workflows import WorkflowType from app.writing_presets import WRITING_PRESETS @@ -38,6 +38,26 @@ def service(mock_client): ) +class TestExternalErrorSanitizing: + def test_sanitize_external_error_masks_secrets_and_normalizes_spacing(self): + hostile_error = ( + " request\x00 failed\tBearer DUMMY_BEARER_TOKEN_123456\n" + "sk-DUMMYKEY1234567890\rapi_key=DUMMY_API_KEY_123456\x1f" + "https://alice:dummy-password@example.invalid/v1 " + ) + + assert sanitize_external_error(hostile_error) == ( + "request failed Bearer [REDACTED] [REDACTED] api_key=[REDACTED] " + "https://[REDACTED]@example.invalid/v1" + ) + + def test_sanitize_external_error_truncates_within_default_limit(self): + result = sanitize_external_error("x" * 300) + + assert len(result) <= 240 + assert result.endswith("…") + + class TestLLMServiceInit: def test_empty_api_key_is_not_available(self, mock_client): service = LLMService(api_key="", client=mock_client, api_key_env="CUSTOM_OPENAI_KEY") diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index c116606..037948a 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -449,6 +449,34 @@ def gui_app(): @gui_only class TestStateMachine: + def test_worker_error_sanitizes_log_tray_and_desktop_notification(self, gui_app, caplog): + hostile_error = ( + " request\x00 failed\tBearer DUMMY_BEARER_TOKEN_123456\n" + "sk-DUMMYKEY1234567890\rapi_key=DUMMY_API_KEY_123456\x1f" + "https://alice:dummy-password@example.invalid/v1 " + ) + expected_message = ( + "request failed Bearer [REDACTED] [REDACTED] api_key=[REDACTED] " + "https://[REDACTED]@example.invalid/v1" + ) + caplog.set_level(logging.DEBUG, logger="blitztext.main") + + with patch.object(gui_app, "show_tray_error") as tray_error, \ + patch("app.blitztext_linux.notify_service.notify") as notify: + gui_app._on_worker_error(hostile_error) + + error_messages = [ + record.getMessage() for record in caplog.records if record.levelno == logging.ERROR + ] + debug_messages = [ + record.getMessage() for record in caplog.records if record.levelno == logging.DEBUG + ] + assert error_messages == [f"Worker error: {expected_message}"] + assert any(hostile_error in message for message in debug_messages) + assert all(hostile_error not in record.getMessage() for record in caplog.records if record.levelno != logging.DEBUG) + assert tray_error.call_args.args[1] == expected_message + assert notify.call_args.args[1] == expected_message + def test_state_returns_to_idle_after_result(self, gui_app): gui_app.state = "LLM_REWRITING" gui_app.current_workflow = WorkflowType.TEXT_IMPROVER From b6ea176f81df2c6e5e6659374240d6c7cb968eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 13:32:08 +0200 Subject: [PATCH 2/6] fix: harden LLM error sanitization --- app/blitztext_linux.py | 9 ++++++-- app/llm_service.py | 32 ++++++++++++++++++++-------- tests/test_llm_service.py | 42 +++++++++++++++++++++++++++++++++++++ tests/test_state_machine.py | 17 +++++++++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index d2253f5..df29808 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -52,6 +52,9 @@ # Set up module logger logger = logging.getLogger("blitztext.main") +_WORKER_ERROR_LOG_PREFIX = "Worker error: " +_MAX_ERROR_LOG_LENGTH = 240 + def _configure_qt_platform() -> None: """Prefer native Wayland when a Wayland session is available.""" @@ -1146,8 +1149,10 @@ def _on_no_speech(self) -> None: @pyqtSlot(str) def _on_worker_error(self, err_msg: str) -> None: logger.debug("Raw worker error: %s", err_msg) - safe_error = sanitize_external_error(err_msg) - logger.error("Worker error: %s", safe_error) + safe_error = sanitize_external_error( + err_msg, max_length=_MAX_ERROR_LOG_LENGTH - len(_WORKER_ERROR_LOG_PREFIX) + ) + logger.error("%s%s", _WORKER_ERROR_LOG_PREFIX, safe_error) self._finish_worker_with_error(safe_error, "worker error") def _finish_worker_with_error(self, err_msg: str, reason: str) -> None: diff --git a/app/llm_service.py b/app/llm_service.py index 5a71b92..6921fff 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -3,8 +3,8 @@ import logging import re -from typing import Any, Optional import unicodedata +from typing import Any, Optional from app.config import DEFAULTS from app.workflows import WorkflowType @@ -22,11 +22,14 @@ DEFAULT_LLM_MODEL = DEFAULTS["llm_model"] _REDACTED = "[REDACTED]" -_URL_USERINFO_PATTERN = re.compile(r"(https?://)[^\s/@:]+:[^\s/@]+@", re.IGNORECASE) +_URL_USERINFO_PATTERN = re.compile( + r"\b([A-Za-z][A-Za-z0-9+.-]*://)[^\s/@]*:[^\s/@]+@", re.IGNORECASE +) _BEARER_TOKEN_PATTERN = re.compile(r"\bBearer\s+[^\s,;]+", re.IGNORECASE) -_SK_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]+") +_SK_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])") _NAMED_SECRET_PATTERN = re.compile( - r"\b(api_key|apikey|token|secret|password)\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)", + r"\b(?Papi_key|apikey|token|secret|password)(?P[\"']?)\s*" + r"(?P[:=])\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)", re.IGNORECASE, ) _EMPTY_EXTERNAL_ERROR = "Unbekannter Fehler" @@ -73,16 +76,27 @@ ) +def _canonicalize_external_error(text: str) -> str: + return "".join( + character + if character == "\u200d" or not unicodedata.category(character).startswith("C") + else " " if unicodedata.category(character) == "Cc" else "" + for character in text + ) + + def sanitize_external_error(message: object, max_length: int = 240) -> str: """Return a safe, compact representation of an external error message.""" - text = str(message) + text = _canonicalize_external_error(str(message)) text = _URL_USERINFO_PATTERN.sub(rf"\1{_REDACTED}@", text) text = _BEARER_TOKEN_PATTERN.sub(f"Bearer {_REDACTED}", text) text = _SK_KEY_PATTERN.sub(_REDACTED, text) - text = _NAMED_SECRET_PATTERN.sub(lambda match: f"{match.group(1)}={_REDACTED}", text) - text = "".join( - " " if unicodedata.category(character).startswith("C") else character - for character in text + text = _NAMED_SECRET_PATTERN.sub( + lambda match: ( + f"{match.group('name')}{match.group('quote')}" + f"{match.group('separator')}{_REDACTED}" + ), + text, ) text = " ".join(text.split()) or _EMPTY_EXTERNAL_ERROR limit = max(1, int(max_length)) diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index b080777..ab040bc 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -57,6 +57,48 @@ def test_sanitize_external_error_truncates_within_default_limit(self): assert len(result) <= 240 assert result.endswith("…") + def test_sanitize_external_error_canonicalizes_hidden_characters_before_masking(self): + hostile_error = ( + "Bearer\x00DUMMY_BEARER_TOKEN_123456 " + "api_key\u200b=\u200bDUMMY_API_KEY_123456" + ) + + assert sanitize_external_error(hostile_error) == ( + "Bearer [REDACTED] api_key=[REDACTED]" + ) + + @pytest.mark.parametrize( + ("hostile_error", "expected"), + [ + ( + '{"api_key": "DUMMY_API_KEY_123456"}', + '{"api_key":[REDACTED]}', + ), + ( + "postgresql://alice:dummy-password@example.invalid/db", + "postgresql://[REDACTED]@example.invalid/db", + ), + ( + "https://:dummy-password@example.invalid/v1", + "https://[REDACTED]@example.invalid/v1", + ), + ], + ) + def test_sanitize_external_error_masks_common_secret_representations( + self, hostile_error, expected + ): + assert sanitize_external_error(hostile_error) == expected + + @pytest.mark.parametrize( + "message", + [ + "Unsupported locale sk-SK", + "Provider returned 👨\u200d💻 error", + ], + ) + def test_sanitize_external_error_preserves_legitimate_non_secrets(self, message): + assert sanitize_external_error(message) == message + class TestLLMServiceInit: def test_empty_api_key_is_not_available(self, mock_client): diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index 037948a..114e5cd 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -477,6 +477,23 @@ def test_worker_error_sanitizes_log_tray_and_desktop_notification(self, gui_app, assert tray_error.call_args.args[1] == expected_message assert notify.call_args.args[1] == expected_message + def test_worker_error_limits_the_complete_error_log_record(self, gui_app, caplog): + hostile_error = "x" * 300 + expected_message = "x" * 225 + "…" + caplog.set_level(logging.DEBUG, logger="blitztext.main") + + with patch.object(gui_app, "show_tray_error") as tray_error, \ + patch("app.blitztext_linux.notify_service.notify") as notify: + gui_app._on_worker_error(hostile_error) + + error_messages = [ + record.getMessage() for record in caplog.records if record.levelno == logging.ERROR + ] + assert error_messages == [f"Worker error: {expected_message}"] + assert len(error_messages[0]) <= 240 + assert tray_error.call_args.args[1] == expected_message + assert notify.call_args.args[1] == expected_message + def test_state_returns_to_idle_after_result(self, gui_app): gui_app.state = "LLM_REWRITING" gui_app.current_workflow = WorkflowType.TEXT_IMPROVER From 0bb27bdc6e73ce4e37d47f79263afb8a086dc76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 13:40:58 +0200 Subject: [PATCH 3/6] fix: catch obfuscated LLM error secrets --- app/llm_service.py | 58 +++++++++++++++++++++++++++++++-------- tests/test_llm_service.py | 17 ++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/app/llm_service.py b/app/llm_service.py index 6921fff..39d5a61 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -33,6 +33,12 @@ re.IGNORECASE, ) _EMPTY_EXTERNAL_ERROR = "Unbekannter Fehler" +_SECRET_PATTERNS = ( + _URL_USERINFO_PATTERN, + _BEARER_TOKEN_PATTERN, + _SK_KEY_PATTERN, + _NAMED_SECRET_PATTERN, +) _DAMPF_SYSTEM = ( "Du erhältst ein emotional gesprochenes Transkript. Erkenne zuerst das eigentliche " @@ -85,19 +91,49 @@ def _canonicalize_external_error(text: str) -> str: ) +def _contains_control_obfuscated_secret(text: str) -> bool: + compact_characters: list[str] = [] + source_positions: list[int] = [] + for position, character in enumerate(text): + if unicodedata.category(character).startswith("C"): + continue + compact_characters.append(character) + source_positions.append(position) + + if len(compact_characters) == len(text): + return False + + compact_text = "".join(compact_characters) + for pattern in _SECRET_PATTERNS: + for match in pattern.finditer(compact_text): + source_start = source_positions[match.start()] + source_end = source_positions[match.end() - 1] + 1 + source_text = text[source_start:source_end] + if ( + any(unicodedata.category(character).startswith("C") for character in source_text) + and pattern.fullmatch(_canonicalize_external_error(source_text)) is None + ): + return True + return False + + def sanitize_external_error(message: object, max_length: int = 240) -> str: """Return a safe, compact representation of an external error message.""" - text = _canonicalize_external_error(str(message)) - text = _URL_USERINFO_PATTERN.sub(rf"\1{_REDACTED}@", text) - text = _BEARER_TOKEN_PATTERN.sub(f"Bearer {_REDACTED}", text) - text = _SK_KEY_PATTERN.sub(_REDACTED, text) - text = _NAMED_SECRET_PATTERN.sub( - lambda match: ( - f"{match.group('name')}{match.group('quote')}" - f"{match.group('separator')}{_REDACTED}" - ), - text, - ) + raw_text = str(message) + if _contains_control_obfuscated_secret(raw_text): + text = _EMPTY_EXTERNAL_ERROR + else: + text = _canonicalize_external_error(raw_text) + text = _URL_USERINFO_PATTERN.sub(rf"\1{_REDACTED}@", text) + text = _BEARER_TOKEN_PATTERN.sub(f"Bearer {_REDACTED}", text) + text = _SK_KEY_PATTERN.sub(_REDACTED, text) + text = _NAMED_SECRET_PATTERN.sub( + lambda match: ( + f"{match.group('name')}{match.group('quote')}" + f"{match.group('separator')}{_REDACTED}" + ), + text, + ) text = " ".join(text.split()) or _EMPTY_EXTERNAL_ERROR limit = max(1, int(max_length)) if len(text) <= limit: diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index ab040bc..f3cd897 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -99,6 +99,23 @@ def test_sanitize_external_error_masks_common_secret_representations( def test_sanitize_external_error_preserves_legitimate_non_secrets(self, message): assert sanitize_external_error(message) == message + @pytest.mark.parametrize( + "hostile_error", + [ + "Bear\x00er DUMMY_BEARER_TOKEN_123456", + "api\x00_key=DUMMY_API_KEY_123456", + "Bear\u200der DUMMY_BEARER_TOKEN_123456", + "api\u200d_key=DUMMY_API_KEY_123456", + "postgresql\x00://user:dummy-password@example.invalid/db", + "postgresql:/\x00/user:dummy-password@example.invalid/db", + "postgresql://us\x00er:dummy-password@example.invalid/db", + ], + ) + def test_sanitize_external_error_replaces_control_obfuscated_secret_atoms( + self, hostile_error + ): + assert sanitize_external_error(hostile_error) == "Unbekannter Fehler" + class TestLLMServiceInit: def test_empty_api_key_is_not_available(self, mock_client): From 1f0b9040cc1ddbc07d2ae5937c8f4d972803ed29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 16:37:02 +0200 Subject: [PATCH 4/6] fix: reject Unicode format characters as Bearer delimiters --- app/llm_service.py | 17 ++++++++++++++++- tests/test_llm_service.py | 13 +++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/llm_service.py b/app/llm_service.py index 39d5a61..fdf9d59 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -25,7 +25,13 @@ _URL_USERINFO_PATTERN = re.compile( r"\b([A-Za-z][A-Za-z0-9+.-]*://)[^\s/@]*:[^\s/@]+@", re.IGNORECASE ) -_BEARER_TOKEN_PATTERN = re.compile(r"\bBearer\s+[^\s,;]+", re.IGNORECASE) +_BEARER_TOKEN_VALUE_PATTERN = r"[A-Za-z0-9._~+/=-]{12,}" +_BEARER_TOKEN_PATTERN = re.compile( + rf"\bBearer\s+{_BEARER_TOKEN_VALUE_PATTERN}(?![A-Za-z0-9._~+/=-])", re.IGNORECASE +) +_OBFUSCATED_BEARER_DELIMITER_PATTERN = re.compile( + rf"\bBearer{_BEARER_TOKEN_VALUE_PATTERN}(?![A-Za-z0-9._~+/=-])", re.IGNORECASE +) _SK_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])") _NAMED_SECRET_PATTERN = re.compile( r"\b(?Papi_key|apikey|token|secret|password)(?P[\"']?)\s*" @@ -114,6 +120,15 @@ def _contains_control_obfuscated_secret(text: str) -> bool: and pattern.fullmatch(_canonicalize_external_error(source_text)) is None ): return True + + for match in _OBFUSCATED_BEARER_DELIMITER_PATTERN.finditer(compact_text): + bearer_end = source_positions[match.start() + len("Bearer") - 1] + 1 + token_start = source_positions[match.start() + len("Bearer")] + if any( + unicodedata.category(character) == "Cf" + for character in text[bearer_end:token_start] + ): + return True return False diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index f3cd897..e4a15c7 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -92,6 +92,7 @@ def test_sanitize_external_error_masks_common_secret_representations( @pytest.mark.parametrize( "message", [ + "Bearer status", "Unsupported locale sk-SK", "Provider returned 👨\u200d💻 error", ], @@ -116,6 +117,18 @@ def test_sanitize_external_error_replaces_control_obfuscated_secret_atoms( ): assert sanitize_external_error(hostile_error) == "Unbekannter Fehler" + @pytest.mark.parametrize( + "hostile_error", + [ + "Bearer\u200bDUMMY_BEARER_TOKEN_123456", + "Bearer\u200dDUMMY_BEARER_TOKEN_123456", + ], + ) + def test_sanitize_external_error_replaces_format_obfuscated_bearer_delimiters( + self, hostile_error + ): + assert sanitize_external_error(hostile_error) == "Unbekannter Fehler" + class TestLLMServiceInit: def test_empty_api_key_is_not_available(self, mock_client): From 2f9f09b0f258baf7a9f38ce67fea28ddf61267a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 16:37:29 +0200 Subject: [PATCH 5/6] fix: use native umlauts in German translations --- app/i18n.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/i18n.py b/app/i18n.py index 307236b..62647f5 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -105,9 +105,9 @@ "tts.export.dialog_title": "Audiodatei exportieren", "tts.export.default_filename_prefix": "Blitztext-Audio_", "tts.error.piper_not_found": "Piper nicht gefunden. Installieren: pip install piper-tts und Stimmen nach ~/.local/share/piper-voices legen.", - "tts.error.openai_not_available": "OpenAI Cloud-TTS ist nicht verfuegbar. Bitte OPENAI_API_KEY in ~/.config/blitztext-linux/secrets.env setzen.", + "tts.error.openai_not_available": "OpenAI Cloud-TTS ist nicht verfügbar. Bitte OPENAI_API_KEY in ~/.config/blitztext-linux/secrets.env setzen.", "tts.consent.title": "OpenAI Cloud-TTS aktivieren?", - "tts.consent.message": "OpenAI Cloud-TTS sendet den eingegebenen Text zur Sprachsynthese an die OpenAI-Server. Lokale Texte verlassen damit deinen Rechner.\n\nMoechtest du Cloud-TTS aktivieren?", + "tts.consent.message": "OpenAI Cloud-TTS sendet den eingegebenen Text zur Sprachsynthese an die OpenAI-Server. Lokale Texte verlassen damit deinen Rechner.\n\nMöchtest du Cloud-TTS aktivieren?", "tts.status.openai_ready": "OpenAI Cloud-TTS bereit.", "tts.status.piper_ready": "Piper bereit.", "tts.status.playback": "Wiedergabe…", @@ -115,12 +115,12 @@ "tts.status.no_text": "Kein Text.", "tts.status.no_voice_path": "Keine Stimme in ~/.local/share/piper-voices gefunden.", "tts.status.synthesis": "Synthese…", - "tts.status.openai_not_confirmed": "OpenAI Cloud-TTS wurde nicht bestaetigt.", + "tts.status.openai_not_confirmed": "OpenAI Cloud-TTS wurde nicht bestätigt.", "tts.status.cloud_synthesis": "Cloud-Synthese…", "tts.status.exporting": "Exportiere Audiodatei…", "tts.status.export_done": "Audiodatei exportiert.", - "tts.status.export_ffmpeg_missing": "ffmpeg nicht gefunden. Audio-Export ist nicht verfuegbar.", - "tts.status.export_format_unsupported": "Exportformat nicht unterstuetzt.", + "tts.status.export_ffmpeg_missing": "ffmpeg nicht gefunden. Audio-Export ist nicht verfügbar.", + "tts.status.export_format_unsupported": "Exportformat nicht unterstützt.", "tts.status.missing_wav_output": "WAV-Ausgabe fehlt.", "tts.status.cancelled": "Abgebrochen.", "tts.status.error": "Fehler: {message}", @@ -136,8 +136,8 @@ "history.status.copied": "✓ Kopiert", "history.entry.meta": "{timestamp} · {count} Wörter", "history.note.heading": "Diktat {heading}", - "history.note.merged_heading": "Diktat (zusammengefuehrt) {heading}", - "history.note.merged_filename_prefix": "Diktat-zusammengefuehrt_", + "history.note.merged_heading": "Diktat (zusammengeführt) {heading}", + "history.note.merged_filename_prefix": "Diktat-zusammengeführt_", "tray.show_window": "Fenster anzeigen", "tray.compose": "Text verfassen…", "tray.writing_preset": "Text-Aktion", From b2b82dbd110207e8a1547bf14d06c7e3351da973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=3D=3DTIM=2E=C2=A9=2EB=20=20=3D=3D?= Date: Mon, 31 Aug 2026 16:37:52 +0200 Subject: [PATCH 6/6] refactor: remove unused terminal helper --- app/paste_service.py | 5 ----- tests/test_paste_service.py | 15 --------------- 2 files changed, 20 deletions(-) diff --git a/app/paste_service.py b/app/paste_service.py index 67e7814..521063d 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -95,11 +95,6 @@ def _detect_active_window_class() -> Optional[str]: return window_class or None -def _is_terminal_active() -> bool: - window_class = _detect_active_window_class() - return bool(window_class and window_class in _KNOWN_TERMINAL_WINDOW_CLASSES) - - class PasteServiceError(Exception): """Raised when clipboard write or key injection fails hard.""" diff --git a/tests/test_paste_service.py b/tests/test_paste_service.py index 4be23a4..d67f853 100644 --- a/tests/test_paste_service.py +++ b/tests/test_paste_service.py @@ -15,7 +15,6 @@ PasteService, PasteServiceError, _detect_active_window_class, - _is_terminal_active, ) @@ -81,20 +80,6 @@ def test_returns_none_if_display_not_set(self): run_mock.assert_not_called() -class TestIsTerminalActive: - def test_returns_true_for_terminal_window(self): - with patch("app.paste_service._detect_active_window_class", return_value="konsole"): - assert _is_terminal_active() is True - - def test_returns_false_for_non_terminal_window(self): - with patch("app.paste_service._detect_active_window_class", return_value="firefox"): - assert _is_terminal_active() is False - - def test_returns_false_when_detection_is_none(self): - with patch("app.paste_service._detect_active_window_class", return_value=None): - assert _is_terminal_active() is False - - class TestYdotoolPaste: def test_sends_ctrl_shift_v_when_terminal_active(self): service = PasteService(autopaste=True, key_delay_ms=135)