Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
__pycache__/
*.pyc
.env
.env.*
secrets.env
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
</details>

Expand All @@ -144,7 +144,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast
| **Blitztext :)** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>E</kbd> | ✅ | 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

Expand Down Expand Up @@ -244,15 +244,15 @@ 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"**.

<div align="center">
<img src="docs/screenshots/linux/settings-allgemein.png" alt="Einstellungen Allgemein" width="480">
<br><br>
</div>

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

<details>
<summary><b>Beispiel-Konfiguration & Felderklärung</b></summary>
Expand All @@ -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": {
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 70 additions & 25 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;")
Expand All @@ -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] = []
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
Loading