diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..351ced0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release assets + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to build and validate (for example v0.9.0); no publication' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-assets-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + build: + # The release builder only supports stable numeric versions. Manual runs + # remain available for validating an existing tag, including prereleases. + if: github.event_name != 'release' || github.event.release.prerelease == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - name: Build and validate tag against application version + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: python scripts/build_release.py --tag "$RELEASE_TAG" --output dist + - name: Check archive and checksum + run: | + cd dist + sha256sum --check *.sha256 + python -c 'import glob, tarfile; t = tarfile.open(glob.glob("*.tar.gz")[0]); assert all(n in t.getnames() for n in ("run.sh", "release.json", "app/blitztext_linux.py"))' + - uses: actions/upload-artifact@v4 + with: + name: release-assets + path: dist/* + if-no-files-found: error + publish: + if: github.event_name == 'release' && !github.event.release.draft && !github.event.release.prerelease + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: release-assets + path: dist + - name: Attach public updater assets to the published release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" dist/*.tar.gz dist/*.sha256 diff --git a/README.de.md b/README.de.md index 2156976..bcae646 100644 --- a/README.de.md +++ b/README.de.md @@ -383,11 +383,25 @@ systemctl --user enable --now ydotool.service # nutzt /usr/local/bin/ydotoold --- +## Aufnahme verwerfen + +Während einer Aufnahme erscheint **Verwerfen** direkt bei der Aufnahmesteuerung und im Tray-Menü. Die Aktion beendet die Aufnahme und löscht das temporäre Audio, ohne Transkription oder KI-Verarbeitung zu starten. **Stopp** beendet und verarbeitet die Aufnahme weiterhin. Vorhandener Text und Verlauf bleiben beim Verwerfen erhalten. + +## Updates aus den Einstellungen + +Unter **Einstellungen → Updates → Nach Updates suchen** eine neue Version prüfen und mit **Update herunterladen und installieren** einspielen. Öffentliche stabile GitHub-Releases benötigen weder GitHub-Konto noch Token, Git-Installation oder `git pull`. Die Prüfung erfolgt auf Knopfdruck; beim bloßen Öffnen der Einstellungen wird nichts geladen. Nach der Installation Blitztext beenden und erneut öffnen. + +Updates werden separat im Datenverzeichnis des Benutzers vorbereitet. Konfiguration, Zugangsdaten und Modell-Caches bleiben an ihren bisherigen Orten. Schlägt Download oder Vorbereitung fehl, bleibt die bisherige Version ausgewählt. Die erste Vorbereitung kann mehrere Minuten dauern und benötigt freien Speicher für eine separate Python-Umgebung. Die Erstinstallation stellt weiterhin die Systempakete bereit; ein App-Update benötigt keine Administratorrechte und führt `install.sh` nicht erneut aus. + +Der Updatekanal setzt eine Version voraus, die den Updater bereits enthält. Ältere Installationen müssen diese einmal über den bisherigen Installationsweg beziehen. Experimentelle Flatpak-Builds verwenden ihren eigenen Paketweg. + +Für Release-Ersteller: Ein stabiles Release mit Tag `vX.Y.Z` veröffentlichen, passend zu `app/__init__.py`. Der Release-Workflow baut das versionierte Updatearchiv samt Prüfsumme und hängt es an das Release. Solange diese Dateien fehlen, meldet die App, dass das Release noch nicht installierbar ist. Paketaufbau und Wiederherstellung stehen in der [Update-Dokumentation](docs/release-updates.md). + ## Konfiguration Alles wird lokal unter `~/.config/blitztext-linux/config.json` gespeichert. Diese Datei enthält keine Secrets — der OpenAI-/OpenRouter-Key wird aus einer Umgebungsvariable gelesen (siehe [Secrets](#secrets)). Die Konfigurationsdatei lässt sich direkt aus den Einstellungen öffnen: **Einstellungen → Allgemein → „Konfigurationsdatei öffnen"**. -Der Einstellungs-Dialog hat drei Tabs: +Der Einstellungs-Dialog enthält Spracherkennung, Text & KI, Allgemein und Updates:
diff --git a/README.md b/README.md
index 8c635de..e1479cb 100644
--- a/README.md
+++ b/README.md
@@ -381,11 +381,25 @@ systemctl --user enable --now ydotool.service # uses /usr/local/bin/ydotoold
---
+## Discard a recording
+
+While recording, **Discard** is visible next to the recording controls and in the tray menu. It stops capture and deletes the temporary audio without starting transcription or AI processing. **Stop** still finishes the recording and processes it. Discarding does not clear existing text or history.
+
+## Updates from Settings
+
+Open **Settings → Updates → Check for updates**, then **Download and install update**. Public stable GitHub releases are checked without a GitHub account, token, Git installation, or `git pull`. Checking is manual; nothing is downloaded when you merely open Settings. After installation, quit and reopen Blitztext to use the new version.
+
+Updates are prepared separately in your user data directory. Configuration, credentials and model caches stay in their existing locations. A download or preparation failure keeps the previous version selected. The first preparation can take several minutes and requires free disk space for a separate Python environment. The initial installation still supplies system packages; an app update does not request administrator permissions or rerun `install.sh`.
+
+This update channel requires a version that already includes the updater. Older installations need to acquire that version once through the existing installation path. Experimental Flatpak builds use their packaging channel instead.
+
+Release maintainers: publish a stable release with a `vX.Y.Z` tag matching `app/__init__.py`. The release workflow builds and attaches the versioned update archive and checksum. Until those assets are attached, the app reports that the release is not yet available for installation. See [release update details](docs/release-updates.md) for packaging and recovery.
+
## Configuration
Everything is stored locally under `~/.config/blitztext-linux/config.json`. This file holds no secrets — the OpenAI/OpenRouter key is read from an environment variable (see [Secrets](#secrets)). The configuration file can be opened directly from the settings: **Settings → General → "Open configuration file"**.
-The settings dialog has three tabs:
+The settings dialog includes Speech Recognition, Text & AI, General, and Updates:
diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py
index 4dd464e..0297d92 100644
--- a/app/blitztext_linux.py
+++ b/app/blitztext_linux.py
@@ -485,6 +485,10 @@ def init_ui(self) -> None:
self.tabs.addTab(self._scrollable(tab_general), tq("settings.tab.general"))
+ from app.update_widget import UpdateWidget
+ self.update_widget = UpdateWidget(self)
+ self.tabs.addTab(self._scrollable(self.update_widget), t("updates.tab"))
+
layout.addWidget(self.tabs)
def _target_widgets(item) -> list[QWidget]:
@@ -895,6 +899,11 @@ def setup_tray(self) -> None:
self.action_custom_prompt.setIcon(theme.create_ui_icon("edit", theme.BLITZ_400))
self.action_custom_prompt.triggered.connect(self.show_custom_prompt)
self.menu.addAction(self.action_custom_prompt)
+ self.action_discard = QAction(t("mainwindow.button.discard"), self)
+ self.action_discard.setVisible(self.state == "RECORDING")
+ self.action_discard.setEnabled(self.state == "RECORDING")
+ self.action_discard.triggered.connect(self.gui_discard)
+ self.menu.addAction(self.action_discard)
self.menu.addSeparator()
# Actions für die fünf Workflows
@@ -1017,6 +1026,8 @@ def _refresh_i18n_texts(self) -> None:
self.action_compose.setText(t("tray.compose"))
if hasattr(self, "action_custom_prompt"):
self.action_custom_prompt.setText(t("compose.custom.edit"))
+ if hasattr(self, "action_discard"):
+ self.action_discard.setText(t("mainwindow.button.discard"))
if hasattr(self, "action_transcription"):
self.action_transcription.setText(
f"{tq('workflow.transcription.name')}\t{hotkey_display_name(self.config.transcription_hotkey)}"
@@ -1655,6 +1666,8 @@ def _on_state_changed(self) -> None:
self._main_window.update_state(self.state, self.current_workflow, self._tray_error_message)
def update_tray_state(self) -> None:
+ self.action_discard.setVisible(self.state == "RECORDING")
+ self.action_discard.setEnabled(self.state == "RECORDING")
if self._tray_error_message:
self.tray_icon.setIcon(self._tray_icons["ERROR"])
self.tray_icon.setToolTip(t("tray.tooltip.error").format(message=self._tray_error_message))
diff --git a/app/i18n.py b/app/i18n.py
index 551f362..598d62a 100644
--- a/app/i18n.py
+++ b/app/i18n.py
@@ -14,6 +14,18 @@
TRANSLATIONS: dict[str, dict[str, str]] = {
"de": {
+ "updates.tab": "Updates",
+ "updates.help": "Öffentliche Releases ohne GitHub-Konto laden. Die neue Version wird beim nächsten App-Start verwendet. Einstellungen und Modelle bleiben erhalten.",
+ "updates.check": "Nach Updates suchen",
+ "updates.install": "Update herunterladen und installieren",
+ "updates.ready": "Updates werden nur auf Knopfdruck geprüft.",
+ "updates.status": "Update-Status",
+ "updates.checking": "Suche nach einer neuen Version …",
+ "updates.installing": "Update wird geladen und eingerichtet. Dies kann einige Minuten dauern …",
+ "updates.installed": "Version {version} ist installiert. Blitztext beenden und erneut starten, um sie zu verwenden.",
+ "updates.available": "Version {version} ist verfügbar.",
+ "updates.current": "Sie verwenden die aktuelle Version.",
+ "updates.failed": "Update fehlgeschlagen: {error}",
"app.name": "Blitztext",
"tray.settings": "Einstellungen",
"tray.quit": "Beenden",
@@ -259,6 +271,18 @@
"preset.custom.name": "Eigene Anweisung",
},
"en": {
+ "updates.tab": "Updates",
+ "updates.help": "Download public releases without a GitHub account. The new version is used the next time the app starts. Settings and models are preserved.",
+ "updates.check": "Check for updates",
+ "updates.install": "Download and install update",
+ "updates.ready": "Updates are checked only when requested.",
+ "updates.status": "Update status",
+ "updates.checking": "Checking for a new version …",
+ "updates.installing": "Downloading and preparing the update. This may take several minutes …",
+ "updates.installed": "Version {version} is installed. Quit and reopen Blitztext to use it.",
+ "updates.available": "Version {version} is available.",
+ "updates.current": "You are using the latest version.",
+ "updates.failed": "Update failed: {error}",
"app.name": "Blitztext",
"tray.settings": "Settings",
"tray.quit": "Quit",
diff --git a/app/main_window.py b/app/main_window.py
index b753b7d..975339c 100644
--- a/app/main_window.py
+++ b/app/main_window.py
@@ -64,7 +64,7 @@ def _mic_body_path() -> QPainterPath:
_PANEL_MIN_WIDTH = 212
_PANEL_PADDING = (10, 6, 10, 10) # plus the 1 px QSS border
_TOOL_GAP = 6
-_TOOLBAR_TOP_GAP = 8
+_TOOLBAR_TOP_GAP = 0 # Keep the always-reserved discard row within the compact window height
_HISTORY_BUTTON_WIDTH = 48
@@ -301,6 +301,8 @@ def _setup_ui(self) -> None:
header.addWidget(self._btn_close)
panel.addLayout(header)
+ capture_controls = QHBoxLayout()
+ capture_controls.setSpacing(8)
self._btn_toggle = RecordButton(self._main_panel)
self._btn_toggle.clicked.connect(self._on_toggle_clicked)
panel.addWidget(self._btn_toggle, 0, Qt.AlignmentFlag.AlignHCenter)
@@ -316,7 +318,6 @@ def _setup_ui(self) -> None:
self._timer_label.setObjectName("timerLabel")
status.addWidget(self._timer_label)
panel.addLayout(status)
- panel.addSpacing(4)
switch = QHBoxLayout()
switch.setSpacing(0)
self._seg_local = self._segment_button(t("mainwindow.mode.switch.local"), "left")
@@ -342,6 +343,34 @@ def _setup_ui(self) -> None:
self._mode_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
self._mode_label.clicked.connect(self._show_options_popup)
panel.addWidget(self._mode_label)
+ self._btn_discard = QPushButton(t("mainwindow.button.discard"), self._main_panel)
+ self._btn_discard.setObjectName("discardButton")
+ # Fit the existing 22px summary row, keeping the recording controls stationary.
+ self._btn_discard.setStyleSheet(
+ f"""
+ QPushButton#discardButton {{
+ background: {theme.APP_SURFACE}; color: {theme.STATE_ERROR};
+ border: 1px solid {theme.APP_LINE}; border-radius: 5px;
+ padding: 0 8px; min-height: 22px; max-height: 22px;
+ }}
+ QPushButton#discardButton:hover {{
+ background: {theme.APP_SURFACE_HOVER}; border-color: {theme.STATE_ERROR};
+ }}
+ QPushButton#discardButton:focus {{ border-color: {theme.BLITZ_300}; }}
+ QPushButton#discardButton:pressed {{ background: {theme.APP_SURFACE_SUNK}; }}
+ """
+ )
+ self._btn_discard.setMinimumWidth(82)
+ self._btn_discard.setEnabled(False)
+ self._btn_discard.hide()
+ self._btn_discard.clicked.connect(self._on_discard_clicked)
+ discard_container = QWidget(self._main_panel)
+ discard_container.setMinimumHeight(24)
+ discard_row = QHBoxLayout(discard_container)
+ discard_row.setContentsMargins(0, 0, 0, 0)
+ discard_row.addStretch(1)
+ discard_row.addWidget(self._btn_discard)
+ panel.addWidget(discard_container)
panel.addSpacing(_TOOLBAR_TOP_GAP)
# The tool row is part of the same surface; its width sets the panel width.
@@ -484,11 +513,6 @@ def _setup_options_popup(self) -> None:
self._btn_dictation.setCheckable(True)
self._btn_dictation.clicked.connect(self._on_dictation_clicked)
popup.addWidget(self._btn_dictation)
- self._btn_discard = QPushButton(t("mainwindow.button.discard"), self._popup_surface)
- self._btn_discard.setObjectName("discardButton")
- self._btn_discard.setEnabled(False)
- self._btn_discard.clicked.connect(self._on_discard_clicked)
- popup.addWidget(self._btn_discard)
self._sync_mode_widgets()
self._options_popup.adjustSize()
self._options_popup.hide()
@@ -672,6 +696,7 @@ def update_state(self, state: str, workflow: Optional[WorkflowType], error: Opti
self._btn_toggle.setEnabled(state in ("IDLE", "RECORDING"))
self._btn_toggle.set_mode("RECORDING" if recording else "PROCESSING" if busy else "IDLE")
self._btn_discard.setEnabled(recording)
+ self._btn_discard.setVisible(recording)
for control in (
self._workflow_combo, self._action_combo, self._btn_prompt,
self._provider_combo, self._model_combo, self._preset_combo,
diff --git a/app/update_service.py b/app/update_service.py
new file mode 100644
index 0000000..49362b5
--- /dev/null
+++ b/app/update_service.py
@@ -0,0 +1,342 @@
+"""Anonymous, opt-in release updates. No GUI imports or work at construction time.
+
+Installations are immutable directories. Only a fully provisioned installation
+is published via ``releases/current``; ``previous`` retains the rollback target.
+The caller must restart the application after install_release returns.
+"""
+from __future__ import annotations
+
+import ast
+from dataclasses import dataclass
+import fcntl
+import gzip
+import hashlib
+import io
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import shutil
+import signal
+import subprocess
+import sys
+import tarfile
+import time
+import tempfile
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+
+REPOSITORY = 'TimInTech/blitztext-linux'
+LATEST_URL = f'https://api.github.com/repos/{REPOSITORY}/releases/latest'
+DOWNLOAD_BASE = f'https://github.com/{REPOSITORY}/releases/download/'
+MAX_ARCHIVE = 64 * 1024 * 1024
+MAX_EXPANDED = 256 * 1024 * 1024
+MAX_MEMBERS = 10000
+SEMVER = r'(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)'
+REQUIREMENT = r'[A-Za-z0-9][A-Za-z0-9_.-]*(?:(?:==|>=|<=|~=|>|<)[0-9][A-Za-z0-9.*+!-]*(?:,(?:==|>=|<=|~=|>|<)[0-9][A-Za-z0-9.*+!-]*)*)?'
+
+
+class UpdateError(RuntimeError):
+ """An update could not be checked or safely installed; current is unchanged."""
+
+
+@dataclass(frozen=True)
+class Release:
+ version: str
+ url: str
+ sha256: str
+
+
+def _version(value: str) -> tuple[int, int, int]:
+ if not isinstance(value, str) or len(value) > 64 or not re.fullmatch(SEMVER, value):
+ raise UpdateError('Expected a numeric stable version (MAJOR.MINOR.PATCH).')
+ return tuple(int(part) for part in value.split('.'))
+
+
+def _asset_url(url: str, version: str, suffix: str = '') -> bool:
+ name = f'blitztext-linux-{version}.tar.gz{suffix}'
+ return url in (f'{DOWNLOAD_BASE}{version}/{name}', f'{DOWNLOAD_BASE}v{version}/{name}')
+
+
+def _allowed_url(url: str, *, redirect: bool = False) -> bool:
+ parsed = urllib.parse.urlsplit(url)
+ if parsed.scheme != 'https' or parsed.username or parsed.password or parsed.port not in (None, 443):
+ return False
+ if parsed.fragment:
+ return False
+ if redirect and parsed.hostname in ('release-assets.githubusercontent.com', 'objects.githubusercontent.com'):
+ return True
+ if url == LATEST_URL:
+ return True
+ return bool(re.fullmatch(re.escape(DOWNLOAD_BASE) + 'v?' + SEMVER +
+ r'/blitztext-linux-' + SEMVER + r'\.tar\.gz(?:\.sha256)?', url))
+
+
+class _GitHubRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ if not _allowed_url(newurl, redirect=True):
+ raise UpdateError('Release download redirected to an untrusted URL.')
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
+def _read_version(path: Path) -> str:
+ tree = ast.parse(path.read_text(encoding='utf-8'))
+ for node in tree.body:
+ if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == '__version__' for t in node.targets):
+ value = ast.literal_eval(node.value)
+ _version(value)
+ return value
+ raise UpdateError('Release has no application version.')
+
+
+class UpdateService:
+ def __init__(self):
+ # Deliberately lazy: constructing settings must never access network/disk.
+ pass
+
+ @property
+ def releases_dir(self) -> Path:
+ data = os.environ.get('XDG_DATA_HOME', '')
+ base = Path(data) if data and Path(data).is_absolute() else Path.home() / '.local/share'
+ return base / 'blitztext-linux/releases'
+
+ @staticmethod
+ def _supported():
+ if os.environ.get('FLATPAK_ID') or Path('/.flatpak-info').exists():
+ raise UpdateError('Flatpak updates are unsupported here; update through your Flatpak software manager.')
+
+ def _fetch(self, url: str, limit: int) -> bytes:
+ if not _allowed_url(url):
+ raise UpdateError('Untrusted release URL.')
+ request = urllib.request.Request(url, headers={
+ 'User-Agent': 'BlitztextLinux-Updater', 'Accept': 'application/vnd.github+json'
+ if url == LATEST_URL else 'application/octet-stream',
+ })
+ deadline = time.monotonic() + 180
+ # No credentials, cookies, git, or account configuration are consulted.
+ opener = urllib.request.build_opener(_GitHubRedirect())
+ with opener.open(request, timeout=20) as response:
+ length = response.headers.get('Content-Length')
+ if length is not None and (int(length) < 0 or int(length) > limit):
+ raise UpdateError('Release response exceeds the download limit.')
+ chunks = []; size = 0
+ while True:
+ if time.monotonic() > deadline:
+ raise UpdateError('Release download timed out.')
+ chunk = response.read1(min(65536, limit - size + 1))
+ if not chunk:
+ break
+ size += len(chunk)
+ if size > limit:
+ raise UpdateError('Release response exceeds the download limit.')
+ chunks.append(chunk)
+ if length is not None and size != int(length):
+ raise UpdateError('Incomplete release download.')
+ return b''.join(chunks)
+
+ def check_for_update(self, current_version: str) -> Release | None:
+ self._supported()
+ current = _version(current_version)
+ try:
+ try:
+ payload = json.loads(self._fetch(LATEST_URL, 2 * 1024 * 1024))
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ return None
+ raise
+ if not isinstance(payload, dict):
+ raise UpdateError('Invalid release metadata.')
+ if payload.get('draft') is True or payload.get('prerelease') is True:
+ return None
+ if payload.get('draft') is not False or payload.get('prerelease') is not False:
+ raise UpdateError('Missing release stability metadata.')
+ tag = payload.get('tag_name', '')
+ version = tag[1:] if isinstance(tag, str) and tag.startswith('v') else tag
+ if _version(version) <= current:
+ return None
+ assets = payload.get('assets')
+ if not isinstance(assets, list):
+ raise UpdateError('Missing release assets.')
+ name = f'blitztext-linux-{version}.tar.gz'
+ def asset_named(name, suffix, limit):
+ matches = [a for a in assets if isinstance(a, dict) and a.get('name') == name]
+ if len(matches) != 1:
+ raise UpdateError(f'Missing or ambiguous release asset: {name}')
+ asset = matches[0]
+ if (asset.get('state') != 'uploaded' or type(asset.get('size')) is not int
+ or not 0 < asset['size'] <= limit
+ or not _asset_url(asset.get('browser_download_url'), version, suffix)):
+ raise UpdateError('Invalid release asset URL, state, or size.')
+ return asset
+ asset = asset_named(name, '', MAX_ARCHIVE)
+ digest = asset.get('digest')
+ if digest is None:
+ checksum = asset_named(name + '.sha256', '.sha256', 4096)
+ content = self._fetch(checksum['browser_download_url'], 4096).decode('ascii')
+ match = re.fullmatch(r'([a-fA-F0-9]{64}) [ *]' + re.escape(name) + r'\n?', content)
+ if not match:
+ raise UpdateError('Invalid release checksum file.')
+ sha256 = match[1].lower()
+ elif isinstance(digest, str) and re.fullmatch(r'sha256:[a-fA-F0-9]{64}', digest):
+ sha256 = digest[7:].lower()
+ else:
+ raise UpdateError('Release asset has no valid SHA256 digest.')
+ return Release(version, asset['browser_download_url'], sha256)
+ except UpdateError:
+ raise
+ except Exception as exc:
+ raise UpdateError(f'Could not check for an update: {exc}') from exc
+
+ @staticmethod
+ def _extract(data: bytes, destination: Path):
+ # Bound the entire decompressed stream before tarfile parses PAX/GNU
+ # metadata (those headers are processed before members are yielded).
+ with tempfile.TemporaryFile() as raw:
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as compressed:
+ remaining = MAX_EXPANDED + MAX_MEMBERS * 1024
+ while True:
+ chunk = compressed.read(min(65536, remaining + 1))
+ if not chunk:
+ break
+ remaining -= len(chunk)
+ if remaining < 0:
+ raise UpdateError('Release archive exceeds the decompression limit.')
+ raw.write(chunk)
+ raw.seek(0)
+ with tarfile.open(fileobj=raw, mode='r:') as archive:
+ UpdateService._extract_members(archive, destination)
+
+ @staticmethod
+ def _extract_members(archive, destination: Path):
+ total = 0; seen = set()
+ for count, member in enumerate(archive, 1):
+ path = PurePosixPath(member.name)
+ if (count > MAX_MEMBERS or member.size < 0 or
+ not member.name or path.is_absolute() or '..' in path.parts or
+ '\\' in member.name or str(path) != member.name.rstrip('/') or
+ path.parts[0] not in ('app', 'run.sh', 'release.json', 'LICENSE') or
+ member.name.rstrip('/') in seen or not (member.isfile() or member.isdir())):
+ raise UpdateError('Unsafe release archive member.')
+ seen.add(member.name.rstrip('/'))
+ total += member.size
+ if total > MAX_EXPANDED:
+ raise UpdateError('Release archive exceeds the extraction limit.')
+ target = destination.joinpath(*path.parts)
+ if member.isdir():
+ target.mkdir(parents=True, exist_ok=True)
+ else:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ source = archive.extractfile(member)
+ with target.open('xb') as output:
+ shutil.copyfileobj(source, output, length=65536)
+ target.chmod(0o755 if member.name == 'run.sh' else 0o644)
+
+ @staticmethod
+ def _manifest(path: Path, release: Release) -> dict:
+ for name in ('run.sh', 'app/__init__.py', 'app/blitztext_linux.py', 'release.json'):
+ if not (path / name).is_file():
+ raise UpdateError(f'Release is missing {name}.')
+ manifest = json.loads((path / 'release.json').read_text())
+ if (manifest.get('schema') != 1 or manifest.get('version') != release.version
+ or _read_version(path / 'app/__init__.py') != release.version):
+ raise UpdateError('Release manifest/application version mismatch.')
+ deps = manifest.get('dependencies')
+ torch = manifest.get('torch')
+ if (not isinstance(deps, list) or not deps or len(deps) > 100
+ or not all(isinstance(d, str) and len(d) < 200 and re.fullmatch(REQUIREMENT, d) for d in deps)
+ or not isinstance(torch, str) or not re.fullmatch(REQUIREMENT, torch)
+ or re.split(r'[<>=~]', torch)[0] != 'torch'):
+ raise UpdateError('Invalid release dependency manifest.')
+ return manifest
+
+ @staticmethod
+ def _command(args: list[str], cwd: Path, timeout: int):
+ # Exclude API secrets and pip/Python overrides from dependency processes.
+ env = {k: os.environ[k] for k in ('HOME', 'PATH', 'LANG', 'LC_ALL', 'XDG_CACHE_HOME') if k in os.environ}
+ env.update(PIP_CONFIG_FILE=os.devnull, PIP_DISABLE_PIP_VERSION_CHECK='1',
+ PYTHONNOUSERSITE='1', QT_QPA_PLATFORM='offscreen')
+ with subprocess.Popen(args, cwd=cwd, env=env, stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE, start_new_session=True) as process:
+ try:
+ _, error = process.communicate(timeout=timeout)
+ except subprocess.TimeoutExpired as exc:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.communicate()
+ raise UpdateError('Dependency provisioning timed out.') from exc
+ if process.returncode:
+ raise UpdateError('Dependency validation/provisioning failed: ' + error.decode(errors='replace')[-2000:])
+
+ def _provision(self, path: Path, manifest: dict):
+ # Fresh environment: pip never writes into the running application's venv.
+ # Pip's shared wheel/download cache is intentionally retained (incl. Torch).
+ self._command([sys.executable, '-I', '-m', 'venv', str(path / '.venv')], path, 120)
+ python = str(path / '.venv/bin/python')
+ pip = [python, '-I', '-m', 'pip', 'install', '--disable-pip-version-check',
+ '--no-input', '--timeout', '20', '--retries', '2']
+ self._command(pip + ['--index-url', 'https://download.pytorch.org/whl/cpu', manifest['torch']], path, 1800)
+ self._command(pip + ['--index-url', 'https://pypi.org/simple', *manifest['dependencies']], path, 1800)
+ self._command([python, '-I', '-m', 'pip', 'check'], path, 120)
+ self._command([python, '-I', '-c',
+ 'import sys; sys.path.insert(0, sys.argv[1]); '
+ 'import PyQt6.QtWidgets, evdev, openai, torch, whisper, faster_whisper; '
+ 'import app.blitztext_linux', str(path)], path, 120)
+
+ @staticmethod
+ def _point(root: Path, name: str, target: Path):
+ temporary = root / ('.pointer-' + uuid.uuid4().hex)
+ try:
+ temporary.symlink_to(target.name)
+ os.replace(temporary, root / name)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+ @staticmethod
+ def _ready_target(root: Path, name: str) -> Path | None:
+ pointer = root / name
+ if not pointer.is_symlink():
+ return None
+ target = pointer.resolve()
+ if target.parent == root.resolve() and (target / '.ready').is_file():
+ return target
+ return None
+
+ def install_release(self, release: Release) -> Path:
+ self._supported()
+ _version(release.version)
+ if (not _asset_url(release.url, release.version) or not isinstance(release.sha256, str)
+ or not re.fullmatch(r'[a-fA-F0-9]{64}', release.sha256)):
+ raise UpdateError('Invalid release URL or SHA256 digest.')
+ staging = None
+ try:
+ root = self.releases_dir
+ root.mkdir(parents=True, exist_ok=True)
+ with (root / '.install.lock').open('a') as lock:
+ try:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError as exc:
+ raise UpdateError('Another update installation is already running.') from exc
+ data = self._fetch(release.url, MAX_ARCHIVE)
+ if hashlib.sha256(data).hexdigest() != release.sha256.lower():
+ raise UpdateError('Release SHA256 checksum mismatch.')
+ staging = root / (release.version + '-' + uuid.uuid4().hex)
+ staging.mkdir(mode=0o700)
+ self._extract(data, staging)
+ manifest = self._manifest(staging, release)
+ self._provision(staging, manifest)
+ (staging / '.ready').write_text(release.version + '\n')
+ previous = self._ready_target(root, 'current') or self._ready_target(root, 'previous')
+ if previous:
+ self._point(root, 'previous', previous)
+ self._point(root, 'current', staging)
+ installed = staging
+ staging = None
+ return installed
+ except UpdateError:
+ raise
+ except Exception as exc:
+ raise UpdateError(f'Could not install update: {exc}') from exc
+ finally:
+ if staging is not None:
+ shutil.rmtree(staging, ignore_errors=True)
diff --git a/app/update_widget.py b/app/update_widget.py
new file mode 100644
index 0000000..54da317
--- /dev/null
+++ b/app/update_widget.py
@@ -0,0 +1,143 @@
+"""Nonblocking release updates, independent of settings persistence."""
+from __future__ import annotations
+
+from PyQt6.QtCore import QObject, QRunnable, QThreadPool, Qt, pyqtSignal, pyqtSlot
+from PyQt6.QtWidgets import QApplication, QLabel, QPushButton, QVBoxLayout, QWidget
+
+from app import __version__
+from app.i18n import t
+
+# Keep jobs alive even when their settings dialog is destroyed. Qt disconnects
+# the receiver automatically; no worker ever accesses a widget directly.
+_ACTIVE_JOBS: set = set()
+
+
+class _Signals(QObject):
+ result = pyqtSignal(object)
+ error = pyqtSignal(str)
+
+
+class _Job(QRunnable):
+ def __init__(self, operation):
+ super().__init__()
+ self.operation = operation
+ self.signals = _Signals()
+
+ def run(self):
+ try:
+ self.signals.result.emit(self.operation())
+ except Exception as exc:
+ self.signals.error.emit(str(exc)[:400])
+ finally:
+ _ACTIVE_JOBS.discard(self)
+
+
+class UpdateController(QObject):
+ """Application-owned job and status survive closing the settings dialog."""
+ changed = pyqtSignal()
+
+ def __init__(self, service):
+ super().__init__(QApplication.instance())
+ self.service = service
+ self.release = None
+ self.job = None
+ self.installing = False
+ self.installed = False
+ self.status = 'updates.ready'
+ self.status_args = {}
+
+ def _start(self, operation):
+ job = _Job(operation)
+ job.signals.result.connect(self._finished)
+ job.signals.error.connect(self._failed)
+ self.job = job
+ _ACTIVE_JOBS.add(job)
+ self.changed.emit()
+ QThreadPool.globalInstance().start(job)
+
+ @pyqtSlot()
+ def check(self):
+ if self.job is not None or self.installed:
+ return
+ self.release = None
+ self.installing = False
+ self.status, self.status_args = 'updates.checking', {}
+ service = self.service
+ self._start(lambda: service.check_for_update(__version__))
+
+ @pyqtSlot()
+ def install(self):
+ if self.job is not None or self.release is None or self.installed:
+ return
+ self.installing = True
+ self.status, self.status_args = 'updates.installing', {}
+ service, release = self.service, self.release
+ self._start(lambda: service.install_release(release))
+
+ @pyqtSlot(object)
+ def _finished(self, result):
+ self.job = None
+ if self.installing:
+ self.installed = True
+ self.status = 'updates.installed'
+ self.status_args = {'version': self.release.version}
+ else:
+ self.release = result
+ self.status = 'updates.available' if result else 'updates.current'
+ self.status_args = {'version': result.version} if result else {}
+ self.changed.emit()
+
+ @pyqtSlot(str)
+ def _failed(self, message):
+ self.job = None
+ self.status, self.status_args = 'updates.failed', {'error': message}
+ self.changed.emit()
+
+
+def _default_controller():
+ app = QApplication.instance()
+ controller = getattr(app, '_blitztext_update_controller', None)
+ if controller is None:
+ from app.update_service import UpdateService
+ controller = UpdateController(UpdateService())
+ app._blitztext_update_controller = controller
+ return controller
+
+
+class UpdateWidget(QWidget):
+ def __init__(self, parent=None, *, service=None, controller=None):
+ super().__init__(parent)
+ self._controller = controller or (
+ UpdateController(service) if service is not None else _default_controller()
+ )
+ layout = QVBoxLayout(self)
+ layout.addWidget(QLabel(t('settings.version').format(version=__version__)))
+ help_label = QLabel(t('updates.help'))
+ help_label.setWordWrap(True)
+ layout.addWidget(help_label)
+ self.check_button = QPushButton(t('updates.check'))
+ self.check_button.clicked.connect(self._controller.check)
+ layout.addWidget(self.check_button)
+ self.install_button = QPushButton(t('updates.install'))
+ self.install_button.clicked.connect(self._controller.install)
+ layout.addWidget(self.install_button)
+ self.status_label = QLabel()
+ self.status_label.setWordWrap(True)
+ self.status_label.setTextFormat(Qt.TextFormat.PlainText)
+ self.status_label.setAccessibleName(t('updates.status'))
+ layout.addWidget(self.status_label)
+ layout.addStretch()
+ self._controller.changed.connect(self._refresh)
+ self._refresh()
+
+ @property
+ def _job(self):
+ return self._controller.job
+
+ @pyqtSlot()
+ def _refresh(self):
+ controller = self._controller
+ idle = controller.job is None and not controller.installed
+ self.check_button.setEnabled(idle)
+ self.install_button.setEnabled(idle and controller.release is not None)
+ self.status_label.setText(t(controller.status).format(**controller.status_args))
diff --git a/docs/release-updates.md b/docs/release-updates.md
new file mode 100644
index 0000000..64b030c
--- /dev/null
+++ b/docs/release-updates.md
@@ -0,0 +1,101 @@
+# Release updates
+
+Blitztext checks public stable releases from `TimInTech/blitztext-linux` only when
+requested in **Settings → Updates**. It sends no GitHub authentication. A release
+must have a numeric `X.Y.Z` version (optional `v` prefix on its tag) newer than the
+running app; drafts and prereleases are not offered.
+
+## Publishing a compatible release
+
+1. Set `app.__version__` in `app/__init__.py` to the release version.
+2. Commit the intended application and tag that commit `vX.Y.Z` (replace `X.Y.Z`
+ with that version). Publish a stable GitHub release for the tag.
+3. `.github/workflows/release.yml` builds and uploads
+ `blitztext-linux-X.Y.Z.tar.gz` and the matching `.tar.gz.sha256` file. The job
+ refuses a version/tag mismatch. Publishing assets requires the workflow's
+ repository-scoped GitHub token; end users need no token or GitHub account.
+
+A manual workflow run builds artifacts for an existing tag without publishing
+assets. To test the current version locally:
+
+```bash
+python3 scripts/build_release.py --tag "v$(python3 -c 'from app import __version__; print(__version__)')" --output /tmp/blitztext-release-check
+(cd /tmp/blitztext-release-check && sha256sum --check *.sha256)
+```
+
+The archive contains application code/assets, `run.sh`, `LICENSE` and a generated
+`release.json`. It excludes Git data, local configuration, credentials, tests and
+the development environment. Runtime dependency requirements live in
+`scripts/build_release.py`; update them when a release requires new dependencies.
+An existing public release without these assets cannot be installed by the updater.
+The updater uses GitHub's asset SHA256 digest, falling back to the attached
+checksum file when GitHub provides no digest. This checks integrity against the
+publisher's GitHub release; it is not an independent publisher signature.
+
+## Installation and startup
+
+The updater downloads over HTTPS with size and time limits, checks SHA256, and
+rejects archive traversal, links and special files. It installs into a unique
+version directory below `${XDG_DATA_HOME:-~/.local/share}/blitztext-linux/releases`.
+Only absolute `XDG_DATA_HOME` values are used; relative values use the home fallback.
+A process lock prevents concurrent installations.
+
+Each release receives a separate Python virtual environment. CPU-only Torch is
+installed first, then the manifest's dependencies from PyPI. Pip's cache is reused;
+the running app's environment is not modified. Dependency checks and imports must
+succeed before a `.ready` marker is written and the `current` symlink is switched
+atomically. The old managed release is retained through `previous`. Download,
+checksum, extraction or preparation errors leave `current` unchanged.
+
+A first update can require substantial disk space and download time for the Python
+runtime dependencies. System packages remain the responsibility of the initial
+installer. An update cannot silently install new apt packages or repair missing
+host libraries. Settings, credentials, history and Whisper model caches stay in
+their existing locations.
+
+The installed service continues to invoke the original checkout's `run.sh`.
+That launcher selects the ready managed release at the next start; an incomplete
+or broken pointer falls back to `previous`, then the original checkout. Launching
+`app/blitztext_linux.py` directly bypasses this selection. Application failures
+after successful startup are not automatically rolled back.
+
+Close and reopen Settings to view an ongoing update; its state belongs to the
+application. After success, quit and restart Blitztext. No live files are replaced
+inside the running app. Existing versions without this updater need one initial
+upgrade through their previous installation method. Flatpak uses its own package
+manager and does not install these archives.
+
+## Recovery
+
+Quit Blitztext before changing release pointers. To select the retained previous
+managed release:
+
+```bash
+python3 - <<'PY'
+import os
+from pathlib import Path
+base = Path(os.environ.get('XDG_DATA_HOME', ''))
+if not base.is_absolute():
+ base = Path.home() / '.local/share'
+root = base / 'blitztext-linux/releases'
+previous = root / 'previous'
+if not previous.is_symlink():
+ raise SystemExit('No previous managed release is available.')
+target = previous.resolve()
+if target.parent != root.resolve() or not (target / '.ready').is_file():
+ raise SystemExit('Previous release is not ready.')
+pointer = root / '.rollback-current'
+pointer.symlink_to(target.name)
+os.replace(pointer, root / 'current')
+print('Previous release selected. Restart Blitztext.')
+PY
+```
+
+Before the first managed update there is no `previous` managed release. To return
+to the original checkout, quit Blitztext and remove only the `current` and
+`previous` symlinks in the releases directory, then launch the original `run.sh`.
+The release directories and original checkout are retained. Old release
+directories can be removed manually once they are neither running nor selected by
+`current`/`previous`; the updater does not delete previous installations for you.
+
+API reference: [GitHub REST releases](https://docs.github.com/en/rest/releases/releases).
diff --git a/run.sh b/run.sh
index 7ad0258..f54c5ff 100755
--- a/run.sh
+++ b/run.sh
@@ -3,7 +3,26 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-VENV_PYTHON="${SCRIPT_DIR}/.venv/bin/python"
+# Resolve the pointer once, then invoke Python directly: no launcher recursion.
+# Incomplete or broken current installations fall back to the retained release.
+DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
+if [[ "${DATA_HOME}" != /* ]]; then DATA_HOME="${HOME}/.local/share"; fi
+RELEASES_DIR="${DATA_HOME}/blitztext-linux/releases"
+APP_DIR="${SCRIPT_DIR}"
+if [[ -z "${FLATPAK_ID:-}" && ! -f /.flatpak-info ]]; then
+ RELEASES_REAL="$(readlink -f -- "${RELEASES_DIR}" 2>/dev/null || true)"
+ for POINTER in current previous; do
+ [[ -L "${RELEASES_DIR}/${POINTER}" ]] || continue
+ TARGET="$(readlink -f -- "${RELEASES_DIR}/${POINTER}" 2>/dev/null || true)"
+ [[ -n "${TARGET}" && "$(dirname "${TARGET}")" == "${RELEASES_REAL}" ]] || continue
+ [[ "$(basename "${TARGET}")" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-[a-f0-9]+$ ]] || continue
+ if [[ -f "${TARGET}/.ready" && -f "${TARGET}/app/blitztext_linux.py" && -x "${TARGET}/.venv/bin/python" ]]; then
+ APP_DIR="${TARGET}"
+ break
+ fi
+ done
+fi
+VENV_PYTHON="${APP_DIR}/.venv/bin/python"
LOCKFILE="${XDG_RUNTIME_DIR:-/tmp}/blitztext_linux.pid"
SECRETS_FILE="${HOME}/.config/blitztext-linux/secrets.env"
@@ -43,4 +62,4 @@ if [[ ! -x "${VENV_PYTHON}" ]]; then
exit 1
fi
-exec "${VENV_PYTHON}" "${SCRIPT_DIR}/app/blitztext_linux.py" "$@"
+exec "${VENV_PYTHON}" "${APP_DIR}/app/blitztext_linux.py" "$@"
diff --git a/scripts/build_release.py b/scripts/build_release.py
new file mode 100644
index 0000000..197885a
--- /dev/null
+++ b/scripts/build_release.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Build the dedicated updater asset and checksum, without git or dependencies.
+
+Usage: python scripts/build_release.py --tag v0.9.0 --output dist
+Edit DEPENDENCIES/TORCH when a future release changes its runtime requirements.
+Only application code/assets, launcher, license and generated manifest ship.
+"""
+from __future__ import annotations
+
+import argparse
+import ast
+import gzip
+import hashlib
+import io
+import json
+from pathlib import Path
+import tarfile
+
+DEPENDENCIES = ['PyQt6', 'evdev', 'openai', 'openai-whisper', 'faster-whisper']
+TORCH = 'torch'
+MAX_VERSION_LENGTH = 64
+
+
+def _is_stable_version(value: str) -> bool:
+ if not isinstance(value, str) or len(value) > MAX_VERSION_LENGTH:
+ return False
+ parts = value.split('.')
+ return len(parts) == 3 and all(
+ part.isascii() and part.isdecimal() and (part == '0' or not part.startswith('0'))
+ for part in parts
+ )
+
+
+def build_release(source: Path, output: Path, tag: str) -> Path:
+ source = Path(source); output = Path(output)
+ version = tag[1:] if tag.startswith('v') else tag
+ if not _is_stable_version(version):
+ raise ValueError('Tag must be a stable numeric version, optionally prefixed with v.')
+ tree = ast.parse((source / 'app/__init__.py').read_text())
+ versions = [ast.literal_eval(n.value) for n in tree.body if isinstance(n, ast.Assign)
+ and any(isinstance(t, ast.Name) and t.id == '__version__' for t in n.targets)]
+ if versions != [version]:
+ raise ValueError('Release tag does not match app.__version__.')
+ for required in ('app/blitztext_linux.py', 'run.sh', 'LICENSE'):
+ if not (source / required).is_file():
+ raise ValueError(f'Missing release file: {required}')
+ files = [source / 'run.sh', source / 'LICENSE']
+ for path in sorted((source / 'app').rglob('*')):
+ if path.is_symlink():
+ raise ValueError(f'Symlinks may not be packaged: {path}')
+ relative = path.relative_to(source / 'app')
+ if '__pycache__' in relative.parts or any(p.startswith('.') for p in relative.parts):
+ continue
+ if path.is_file() and (path.suffix == '.py' or
+ (relative.parts[0] == 'assets' and path.suffix in ('.svg', '.png', '.ico', '.jpg', '.webp'))):
+ files.append(path)
+ if any(p.is_symlink() for p in files) or (source / 'app').is_symlink():
+ raise ValueError('Symlinks may not be packaged.')
+ manifest = json.dumps(dict(schema=1, version=version, dependencies=DEPENDENCIES, torch=TORCH),
+ indent=2).encode() + b'\n'
+ output.mkdir(parents=True, exist_ok=True)
+ target = output / f'blitztext-linux-{version}.tar.gz'
+ with target.open('wb') as raw, gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as compressed, tarfile.open(fileobj=compressed, mode='w') as tar:
+ for path in files:
+ content = path.read_bytes()
+ info = tarfile.TarInfo(path.relative_to(source).as_posix())
+ info.size = len(content); info.mode = 0o755 if info.name == 'run.sh' else 0o644
+ tar.addfile(info, io.BytesIO(content))
+ info = tarfile.TarInfo('release.json'); info.size = len(manifest); info.mode = 0o644
+ tar.addfile(info, io.BytesIO(manifest))
+ Path(str(target) + '.sha256').write_text(hashlib.sha256(target.read_bytes()).hexdigest() + ' ' + target.name + '\n')
+ return target
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--tag', required=True)
+ parser.add_argument('--output', type=Path, default=Path('dist'))
+ parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[1])
+ args = parser.parse_args()
+ try:
+ print(build_release(args.source, args.output, args.tag))
+ except (ValueError, OSError) as exc:
+ parser.error(str(exc))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/gui_usability_check.py b/scripts/gui_usability_check.py
index d69b1bc..a2848fa 100644
--- a/scripts/gui_usability_check.py
+++ b/scripts/gui_usability_check.py
@@ -23,6 +23,7 @@
("C_dictate", "Dictate with text improvement (start and stop)", 3),
("D_model", "Switch the Whisper model to 'tiny'", 3),
("E_settings", "Open the settings dialog", 1),
+ ("F_discard", "Discard a running recording without transcribing it", 1),
)
@@ -96,6 +97,18 @@ def _controller_task(task_id, window, app, outdir, recorder):
s.click(window._mode_label, "Zustandszeile (Details)")
s.choose(window._model_combo, "tiny", "Whisper-Modell")
goal = ("model", "tiny") in recorder.calls
+ elif task_id == "F_discard":
+ window.update_state("RECORDING", WorkflowType.TRANSCRIPTION, None)
+ s._shot("state: recording")
+ visible = window._btn_discard.isVisible()
+ s.click(window._btn_discard, "Verwerfen")
+ window.update_state("IDLE", None, None)
+ s._shot("state: ready")
+ ready = window._status_label.text()
+ from app.i18n import t
+ goal = visible and ready == t("mainwindow.status.ready") and ("gui_discard",) in recorder.calls and not any(
+ call[0] == "gui_toggle_recording" for call in recorder.calls
+ )
else:
s.click(window._btn_settings, "Zahnrad")
goal = ("show_settings_dialog",) in recorder.calls
diff --git a/tests/test_build_release.py b/tests/test_build_release.py
new file mode 100644
index 0000000..89294bf
--- /dev/null
+++ b/tests/test_build_release.py
@@ -0,0 +1,56 @@
+import hashlib
+import json
+import tarfile
+from pathlib import Path
+
+import pytest
+
+from scripts.build_release import build_release
+
+
+@pytest.fixture
+def source(tmp_path):
+ root = tmp_path / 'source'; (root / 'app/assets').mkdir(parents=True)
+ (root / 'app/__init__.py').write_text('__version__ = "1.2.3"\n')
+ (root / 'app/blitztext_linux.py').write_text('pass\n')
+ (root / 'app/assets/icon.svg').write_text('')
+ (root / 'run.sh').write_text('#!/bin/bash\n')
+ (root / 'LICENSE').write_text('license')
+ (root / 'app/__pycache__').mkdir()
+ (root / 'app/__pycache__/x.pyc').write_text('cache')
+ (root / 'secrets.env').write_text('PRIVATE=value')
+ (root / '.venv').mkdir()
+ return root
+
+
+def test_package_contains_runtime_and_checksum_only(source, tmp_path):
+ path = build_release(source, tmp_path / 'dist', 'v1.2.3')
+ assert path.name == 'blitztext-linux-1.2.3.tar.gz'
+ assert Path(str(path) + '.sha256').read_text() == hashlib.sha256(path.read_bytes()).hexdigest() + ' ' + path.name + '\n'
+ with tarfile.open(path) as tar:
+ names = tar.getnames()
+ assert 'app/assets/icon.svg' in names and 'run.sh' in names
+ assert not any('secret' in n or '.venv' in n or '__pycache__' in n for n in names)
+ manifest = json.load(tar.extractfile('release.json'))
+ assert manifest['version'] == '1.2.3'
+ assert 'openai-whisper' in manifest['dependencies']
+ assert manifest['torch'] == 'torch'
+ assert tar.getmember('run.sh').mode == 0o755
+
+
+@pytest.mark.parametrize('tag', ['v1.2.4', '1.2.3rc1', 'v01.2.3', '../../bad', '1' * 65])
+def test_tag_must_match_version(source, tmp_path, tag):
+ with pytest.raises(ValueError): build_release(source, tmp_path / 'dist', tag)
+
+
+def test_refuses_symlink_in_runtime(source, tmp_path):
+ (source / 'app/leak.py').symlink_to(source / 'secrets.env')
+ with pytest.raises(ValueError): build_release(source, tmp_path / 'dist', 'v1.2.3')
+
+
+def test_identical_sources_produce_identical_archive(source, tmp_path):
+ first = build_release(source, tmp_path / 'first', 'v1.2.3').read_bytes()
+ import time
+ time.sleep(1.05)
+ second = build_release(source, tmp_path / 'second', 'v1.2.3').read_bytes()
+ assert first == second
diff --git a/tests/test_gui_usability.py b/tests/test_gui_usability.py
index b12aff1..dd00b1f 100644
--- a/tests/test_gui_usability.py
+++ b/tests/test_gui_usability.py
@@ -68,3 +68,9 @@ def test_no_blocking_accessibility_findings(report):
@gui_only
def test_report_is_json_serialisable(report):
json.dumps(report)
+
+
+def test_discard_is_a_one_click_usability_task():
+ tasks = {task[0]: task for task in _load().TASKS}
+ assert 'F_discard' in tasks
+ assert tasks['F_discard'][2] == 1
diff --git a/tests/test_main_window_widget.py b/tests/test_main_window_widget.py
index 538cb22..d33b28b 100644
--- a/tests/test_main_window_widget.py
+++ b/tests/test_main_window_widget.py
@@ -183,7 +183,7 @@ def test_options_are_shown_only_in_transient_popup(qapp):
qapp.processEvents()
assert window._model_combo.isVisible()
assert window._btn_dictation.isVisible()
- assert window._btn_discard.isVisible()
+ assert not window._btn_discard.isVisible()
window._options_popup.hide()
assert window._options_popup.isVisible() is False
@@ -268,11 +268,13 @@ def test_recording_state_enables_discard_and_updates_gold_widget(qapp):
assert window._btn_toggle._anim.isActive()
assert window._btn_toggle.text() == "Stopp"
assert window._btn_discard.isEnabled()
+ assert window._btn_discard.isVisible()
window.update_state("TRANSCRIBING", WorkflowType.TRANSCRIPTION, None)
assert window._btn_toggle.mode == "PROCESSING"
assert window._btn_toggle._anim.isActive()
assert window._btn_discard.isEnabled() is False
+ assert not window._btn_discard.isVisible()
assert window._timer_label.isVisible() is False
window.update_state("IDLE", WorkflowType.TRANSCRIPTION, None)
@@ -824,3 +826,30 @@ def test_wide_status_text_never_widens_the_window(qapp):
window.set_history_count(999)
qapp.processEvents()
assert window.size() == size
+
+
+@gui_only
+def test_discard_is_directly_clickable_only_while_recording(qapp):
+ from PyQt6.QtCore import Qt
+ from PyQt6.QtTest import QTest
+ from app.main_window import MainWindow
+ from app.workflows import WorkflowType
+
+ controller = _Controller()
+ window = MainWindow(controller)
+ window.show()
+ try:
+ for state in ("IDLE", "RECORDING", "TRANSCRIBING", "LLM_REWRITING", "IDLE"):
+ window.update_state(state, WorkflowType.TRANSCRIPTION, None)
+ qapp.processEvents()
+ assert not window._options_popup.isVisible()
+ assert window._btn_discard.isVisible() == (state == "RECORDING")
+ assert window._btn_discard.isEnabled() == (state == "RECORDING")
+ if state == "RECORDING":
+ assert window._btn_discard.height() >= 22
+ assert window._btn_discard.width() >= 22
+ assert window.rect().contains(window._btn_discard.mapTo(window, window._btn_discard.rect().center()))
+ QTest.mouseClick(window._btn_discard, Qt.MouseButton.LeftButton)
+ assert controller.calls == ["discard"]
+ finally:
+ window.close()
diff --git a/tests/test_recording_discard.py b/tests/test_recording_discard.py
new file mode 100644
index 0000000..6861f78
--- /dev/null
+++ b/tests/test_recording_discard.py
@@ -0,0 +1,63 @@
+"""Discard uses the real controller handlers without starting a transcription pipeline."""
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+from app.blitztext_linux import BlitztextApp
+from app.workflows import WorkflowType
+
+
+@pytest.fixture
+def controller(monkeypatch):
+ recorder = Mock()
+ recorder.is_recording.return_value = False
+ app = SimpleNamespace(
+ state="RECORDING", current_workflow=WorkflowType.TRANSCRIPTION,
+ _recording_routes_to_compose=True,
+ config=SimpleNamespace(hotkey_mode="hold"),
+ audio_recorder=recorder, show_tray_error=Mock(),
+ )
+ app._set_state = lambda state, reason: setattr(app, "state", state)
+ # Keep the real processing path so an accidental call cannot be swallowed.
+ app._stop_recording_and_process = lambda: BlitztextApp._stop_recording_and_process(app)
+ worker = Mock(side_effect=AssertionError("discard must not create a transcription worker"))
+ monkeypatch.setattr("app.blitztext_linux._TranscribeWorker", worker)
+ return app, worker
+
+
+@pytest.mark.parametrize("mode", ["hold", "toggle"])
+def test_discard_and_delayed_release_never_transcribe(controller, mode):
+ app, worker = controller
+ app.config.hotkey_mode = mode
+ BlitztextApp.gui_discard(app)
+ BlitztextApp._on_recording_stop(app)
+ BlitztextApp.gui_discard(app)
+
+ assert app.state == "IDLE"
+ assert app.current_workflow is None
+ assert not app._recording_routes_to_compose
+ app.audio_recorder.discard.assert_called_once()
+ app.audio_recorder.stop.assert_not_called()
+ worker.assert_not_called()
+ app.show_tray_error.assert_not_called()
+
+
+def test_failed_discard_retains_recording_and_can_be_retried(controller):
+ app, worker = controller
+ app.audio_recorder.is_recording.return_value = True
+ BlitztextApp.gui_discard(app)
+
+ assert app.state == "RECORDING"
+ assert app.current_workflow == WorkflowType.TRANSCRIPTION
+ assert app._recording_routes_to_compose
+ app.show_tray_error.assert_called_once()
+ app.audio_recorder.stop.assert_not_called()
+ worker.assert_not_called()
+
+ app.audio_recorder.is_recording.return_value = False
+ BlitztextApp.gui_discard(app)
+ assert app.state == "IDLE"
+ assert app.current_workflow is None
+ assert not app._recording_routes_to_compose
+ assert app.audio_recorder.discard.call_count == 2
diff --git a/tests/test_run_script.py b/tests/test_run_script.py
index f39ca8a..47139d9 100644
--- a/tests/test_run_script.py
+++ b/tests/test_run_script.py
@@ -30,6 +30,7 @@ def _run_script(script_path: Path, home_dir: Path, runtime_dir: Path) -> subproc
env = os.environ.copy()
env.update({
"HOME": str(home_dir),
+ "XDG_DATA_HOME": str(home_dir / ".local/share"),
"XDG_RUNTIME_DIR": str(runtime_dir),
})
proc = subprocess.Popen(
@@ -101,3 +102,44 @@ def test_run_script_stays_quiet_when_file_permissions_are_0600(tmp_path: Path):
assert result.returncode == 0
assert "BT_SAMPLE_VAR=from-env" in result.stdout
assert result.stderr == ""
+
+
+@pytest.mark.parametrize('valid', [True, False])
+def test_dispatches_only_ready_managed_release(tmp_path, valid):
+ repo = Path(__file__).resolve().parents[1]
+ script = _prepare_run_script(tmp_path, repo)
+ home = tmp_path / 'home'; runtime = tmp_path / 'runtime'; runtime.mkdir()
+ releases = home / '.local/share/blitztext-linux/releases'
+ managed = releases / '1.2.0-abcdef'; (managed / 'app').mkdir(parents=True)
+ (managed / 'app/blitztext_linux.py').write_text('pass')
+ python = managed / '.venv/bin/python'; python.parent.mkdir(parents=True)
+ python.write_text('#!/bin/bash\nprintf "managed:%s\\n" "$@"\n'); python.chmod(0o755)
+ if valid: (managed / '.ready').touch()
+ (releases / 'current').symlink_to(managed.name)
+ result = _run_script(script, home, runtime)
+ assert result.returncode == 0
+ assert ('managed:' in result.stdout) is valid
+
+
+@pytest.mark.parametrize('current_state', ['missing', 'unready', 'outside'])
+def test_previous_fallback_and_managed_launcher_do_not_recurse(tmp_path, current_state):
+ repo = Path(__file__).resolve().parents[1]
+ home = tmp_path / 'home'; runtime = tmp_path / 'runtime'; runtime.mkdir()
+ releases = home / '.local/share/blitztext-linux/releases'
+ previous = releases / '1.1.0-abc'; previous.mkdir(parents=True)
+ script = _prepare_run_script(previous, repo)
+ (previous / '.ready').touch()
+ python = previous / '.venv/bin/python'
+ python.write_text('#!/bin/bash\nprintf "previous:%s\\n" "$@"\n'); python.chmod(0o755)
+ (releases / 'previous').symlink_to(previous.name)
+ target = releases / '1.2.0-def'
+ if current_state == 'unready': target.mkdir()
+ if current_state == 'outside':
+ target = tmp_path / 'outside'
+ target.mkdir()
+ _prepare_run_script(target, repo)
+ (target / '.ready').touch()
+ (releases / 'current').symlink_to(target)
+ result = _run_script(script, home, runtime)
+ assert result.returncode == 0
+ assert f'previous:{previous}/app/blitztext_linux.py' in result.stdout
diff --git a/tests/test_tray_preset_menu.py b/tests/test_tray_preset_menu.py
index 0974c22..5927859 100644
--- a/tests/test_tray_preset_menu.py
+++ b/tests/test_tray_preset_menu.py
@@ -878,3 +878,74 @@ def test_settings_dialog_keeps_the_openrouter_variable_when_switching_to_openai(
assert cfg.openai_provider_key_env == ""
tray_app.main_window_provider_changed("openrouter")
assert cfg.openai_api_key_env == "MY_ROUTER_KEY"
+
+
+@gui_only
+def test_tray_discard_only_available_during_recording(tray_app):
+ from unittest.mock import MagicMock
+ from app.i18n import t
+ from app.workflows import WorkflowType
+
+ recorder = MagicMock()
+ recorder.is_recording.return_value = False
+ tray_app.audio_recorder = recorder
+ action = tray_app.action_discard
+ assert action in tray_app.menu.actions()
+ assert action.text() == t("mainwindow.button.discard")
+ for state in ("IDLE", "TRANSCRIBING", "LLM_REWRITING", "RECORDING"):
+ tray_app._set_state(state, "test")
+ assert action.isVisible() == (state == "RECORDING")
+ assert action.isEnabled() == (state == "RECORDING")
+ tray_app.current_workflow = WorkflowType.TRANSCRIPTION
+ tray_app._recording_routes_to_compose = True
+ action.trigger()
+ recorder.discard.assert_called_once()
+ recorder.stop.assert_not_called()
+ assert tray_app.state == "IDLE"
+ assert tray_app.current_workflow is None
+ assert not tray_app._recording_routes_to_compose
+ assert not action.isVisible()
+ assert not action.isEnabled()
+
+
+@gui_only
+def test_tray_discard_failed_stop_remains_available_for_retry(tray_app, monkeypatch):
+ from unittest.mock import MagicMock
+ from app.workflows import WorkflowType
+
+ recorder = MagicMock()
+ recorder.is_recording.return_value = True
+ tray_app.audio_recorder = recorder
+ monkeypatch.setattr(tray_app, "show_tray_error", lambda *args: tray_app.update_tray_state())
+ tray_app.current_workflow = WorkflowType.TRANSCRIPTION
+ tray_app._recording_routes_to_compose = True
+ tray_app._set_state("RECORDING", "test")
+
+ tray_app.action_discard.trigger()
+
+ assert tray_app.state == "RECORDING"
+ assert tray_app.current_workflow == WorkflowType.TRANSCRIPTION
+ assert tray_app._recording_routes_to_compose
+ assert tray_app.action_discard.isEnabled()
+ assert tray_app.action_discard.isVisible()
+ recorder.stop.assert_not_called()
+
+ recorder.is_recording.return_value = False
+ tray_app.action_discard.trigger()
+ assert tray_app.state == "IDLE"
+ assert not tray_app.action_discard.isVisible()
+
+
+@gui_only
+def test_tray_discard_label_refreshes_with_language(tray_app):
+ from app.i18n import get_language, set_language
+
+ original = get_language()
+ try:
+ for language, label in (("en", "Discard"), ("de", "Verwerfen")):
+ set_language(language)
+ tray_app._refresh_i18n_texts()
+ assert tray_app.action_discard.text() == label
+ finally:
+ set_language(original)
+ tray_app._refresh_i18n_texts()
diff --git a/tests/test_update_service.py b/tests/test_update_service.py
new file mode 100644
index 0000000..2074f9f
--- /dev/null
+++ b/tests/test_update_service.py
@@ -0,0 +1,289 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import tarfile
+from pathlib import Path
+from urllib.error import HTTPError
+
+import pytest
+
+from app import update_service as u
+
+URL = 'https://github.com/TimInTech/blitztext-linux/releases/download/v1.2.0/blitztext-linux-1.2.0.tar.gz'
+
+
+@pytest.fixture
+def service(tmp_path, monkeypatch):
+ monkeypatch.setenv('XDG_DATA_HOME', str(tmp_path))
+ monkeypatch.delenv('FLATPAK_ID', raising=False)
+ return u.UpdateService()
+
+
+def metadata(**changes):
+ result = dict(tag_name='v1.2.0', draft=False, prerelease=False, assets=[dict(
+ name='blitztext-linux-1.2.0.tar.gz', browser_download_url=URL,
+ digest='sha256:' + 'a' * 64, size=100, state='uploaded')])
+ result.update(changes)
+ return result
+
+
+def serve(monkeypatch, payload):
+ monkeypatch.setattr(u.UpdateService, '_fetch', lambda self, url, limit: json.dumps(payload).encode())
+
+
+def archive(extra=None, version='1.2.0'):
+ files = {'app/__init__.py': f'__version__ = "{version}"\n'.encode(),
+ 'app/blitztext_linux.py': b'pass\n', 'run.sh': b'#!/bin/bash\n',
+ 'release.json': json.dumps(dict(schema=1, version=version,
+ dependencies=['PyQt6', 'evdev', 'openai', 'openai-whisper', 'faster-whisper'],
+ torch='torch')).encode()}
+ data = io.BytesIO()
+ with tarfile.open(fileobj=data, mode='w:gz') as tar:
+ for name, content in files.items():
+ info = tarfile.TarInfo(name); info.size = len(content)
+ tar.addfile(info, io.BytesIO(content))
+ if extra:
+ tar.addfile(extra, io.BytesIO(b'x' * extra.size))
+ return data.getvalue()
+
+
+def install_fixture(service, monkeypatch, data):
+ monkeypatch.setattr(service, '_fetch', lambda url, limit: data)
+ def provision(path, manifest):
+ python = path / '.venv/bin/python'
+ python.parent.mkdir(parents=True)
+ python.write_text('#!/bin/sh\nexit 0\n'); python.chmod(0o755)
+ monkeypatch.setattr(service, '_provision', provision)
+ return u.Release('1.2.0', URL, hashlib.sha256(data).hexdigest())
+
+
+def test_latest_release_numeric_comparison(service, monkeypatch):
+ serve(monkeypatch, metadata())
+ assert service.check_for_update('1.1.99') == u.Release('1.2.0', URL, 'a' * 64)
+ assert service.check_for_update('1.10.0') is None
+ assert service.check_for_update('1.2.0') is None
+
+
+@pytest.mark.parametrize('version', ['01.2.0', '1.2', '1.2.0rc1', '1.2.0+build', '1.2.0', '../1.2.0'])
+def test_strict_versions(service, monkeypatch, version):
+ serve(monkeypatch, metadata(tag_name=version))
+ with pytest.raises(u.UpdateError): service.check_for_update('0.9.0')
+
+
+@pytest.mark.parametrize('flag', ['draft', 'prerelease'])
+def test_unstable_is_not_offered(service, monkeypatch, flag):
+ serve(monkeypatch, metadata(**{flag: True}))
+ assert service.check_for_update('0.9.0') is None
+
+
+@pytest.mark.parametrize('change', [dict(digest=None), dict(digest='sha256:bad'),
+ dict(browser_download_url='https://evil.example/payload'), dict(size=10**12)])
+def test_missing_integrity_or_bad_asset_rejected(service, monkeypatch, change):
+ payload = metadata(); payload['assets'][0].update(change); serve(monkeypatch, payload)
+ with pytest.raises(u.UpdateError): service.check_for_update('0.9.0')
+
+
+def test_checksum_fallback(service, monkeypatch):
+ payload = metadata(); payload['assets'][0]['digest'] = None
+ checksum_url = URL + '.sha256'
+ payload['assets'].append(dict(name='blitztext-linux-1.2.0.tar.gz.sha256',
+ browser_download_url=checksum_url, size=100, state='uploaded'))
+ monkeypatch.setattr(service, '_fetch', lambda url, limit: json.dumps(payload).encode()
+ if url == u.LATEST_URL else (('b' * 64) + ' blitztext-linux-1.2.0.tar.gz\n').encode())
+ assert service.check_for_update('0.9.0').sha256 == 'b' * 64
+
+
+def test_not_found_is_no_update_but_rate_limit_is_error(service, monkeypatch):
+ def fail(url, limit): raise HTTPError(url, 404, 'missing', {}, None)
+ monkeypatch.setattr(service, '_fetch', fail)
+ assert service.check_for_update('0.9.0') is None
+ def limited(url, limit): raise HTTPError(url, 403, 'rate limit', {}, None)
+ monkeypatch.setattr(service, '_fetch', limited)
+ with pytest.raises(u.UpdateError): service.check_for_update('0.9.0')
+
+
+def test_installs_and_keeps_prior_release(service, monkeypatch):
+ release = install_fixture(service, monkeypatch, archive())
+ first = service.install_release(release)
+ second = service.install_release(release)
+ root = service.releases_dir
+ assert (root / 'current').resolve() == second
+ assert (root / 'previous').resolve() == first
+ assert first.is_dir() and (second / '.ready').is_file()
+ assert (second / 'app/__init__.py').read_text() == '__version__ = "1.2.0"\n'
+
+
+@pytest.mark.parametrize('kind', ['digest', 'provision', 'version'])
+def test_failure_preserves_current(service, monkeypatch, kind):
+ release = install_fixture(service, monkeypatch, archive())
+ first = service.install_release(release)
+ if kind == 'digest': release = u.Release('1.2.0', URL, '0' * 64)
+ elif kind == 'version': release = install_fixture(service, monkeypatch, archive(version='9.0.0'))
+ else:
+ def fail(*args): raise OSError('dependency failure')
+ monkeypatch.setattr(service, '_provision', fail)
+ with pytest.raises(u.UpdateError): service.install_release(release)
+ assert (service.releases_dir / 'current').resolve() == first
+ assert len([p for p in service.releases_dir.iterdir() if p.is_dir() and not p.is_symlink()]) == 1
+
+
+@pytest.mark.parametrize('name,kind', [('../escape', tarfile.REGTYPE), ('/escape', tarfile.REGTYPE),
+ ('app/link', tarfile.SYMTYPE), ('app/hard', tarfile.LNKTYPE), ('app/dev', tarfile.CHRTYPE),
+ ('.venv/bin/python', tarfile.REGTYPE), ('app/__init__.py', tarfile.REGTYPE)])
+def test_unsafe_archives_rejected(service, monkeypatch, name, kind):
+ info = tarfile.TarInfo(name); info.type = kind; info.linkname = '/tmp/escape'
+ release = install_fixture(service, monkeypatch, archive(info))
+ with pytest.raises(u.UpdateError): service.install_release(release)
+ assert not (service.releases_dir / 'current').exists()
+
+
+def test_flatpak_is_explicit_error(service, monkeypatch):
+ monkeypatch.setenv('FLATPAK_ID', 'io.github.TimInTech.Blitztext')
+ with pytest.raises(u.UpdateError, match='Flatpak'): service.check_for_update('0.9.0')
+ with pytest.raises(u.UpdateError, match='Flatpak'): service.install_release(u.Release('1.2.0', URL, 'a' * 64))
+
+
+def test_install_does_not_accept_arbitrary_url(service):
+ with pytest.raises(u.UpdateError):
+ service.install_release(u.Release('1.2.0', 'https://evil.example/payload', 'a' * 64))
+
+
+def test_download_limit_and_no_auth(service, monkeypatch):
+ class Response(io.BytesIO):
+ headers = {}
+ class Opener:
+ def open(self, request, timeout):
+ assert not request.has_header('Authorization')
+ assert timeout <= 30
+ return Response(b'12345')
+ monkeypatch.setattr(u.urllib.request, 'build_opener', lambda *args: Opener())
+ with pytest.raises(u.UpdateError): service._fetch(u.LATEST_URL, 4)
+
+
+def test_redirect_rejects_non_github_host():
+ handler = u._GitHubRedirect()
+ request = u.urllib.request.Request(URL)
+ for target in ['http://release-assets.githubusercontent.com/file', 'https://evil.example/x',
+ 'https://github.com/other/repo/file', 'https://user@release-assets.githubusercontent.com/file']:
+ with pytest.raises(u.UpdateError): handler.redirect_request(request, None, 302, '', {}, target)
+
+
+def test_constructor_is_inert_even_when_flatpak(service, monkeypatch):
+ monkeypatch.setenv('FLATPAK_ID', 'test')
+ def forbidden(*args, **kwargs): raise AssertionError('construction performed IO')
+ monkeypatch.setattr(Path, 'exists', forbidden)
+ monkeypatch.setattr(u.urllib.request, 'build_opener', forbidden)
+ u.UpdateService()
+
+
+def test_concurrent_install_is_rejected_without_changing_current(service, monkeypatch):
+ release = install_fixture(service, monkeypatch, archive())
+ first = service.install_release(release)
+ with (service.releases_dir / '.install.lock').open('a') as lock:
+ u.fcntl.flock(lock, u.fcntl.LOCK_EX | u.fcntl.LOCK_NB)
+ with pytest.raises(u.UpdateError, match='already running'):
+ u.UpdateService().install_release(release)
+ assert (service.releases_dir / 'current').resolve() == first
+
+
+def test_failed_pointer_switch_preserves_current(service, monkeypatch):
+ release = install_fixture(service, monkeypatch, archive())
+ first = service.install_release(release)
+ replace = u.os.replace
+ def fail_current(src, dst):
+ if Path(dst).name == 'current': raise OSError('failed atomic switch')
+ replace(src, dst)
+ monkeypatch.setattr(u.os, 'replace', fail_current)
+ with pytest.raises(u.UpdateError): service.install_release(release)
+ assert (service.releases_dir / 'current').resolve() == first
+ assert not list(service.releases_dir.glob('.pointer-*'))
+
+
+@pytest.mark.parametrize('limit', ['MAX_EXPANDED', 'MAX_MEMBERS'])
+def test_extraction_limits_preserve_current(service, monkeypatch, limit):
+ release = install_fixture(service, monkeypatch, archive())
+ first = service.install_release(release)
+ monkeypatch.setattr(u, limit, 1)
+ with pytest.raises(u.UpdateError): service.install_release(release)
+ assert (service.releases_dir / 'current').resolve() == first
+
+
+def test_truncated_download_and_timeout_are_errors(service, monkeypatch):
+ class Response(io.BytesIO):
+ headers = {'Content-Length': '8'}
+ class Opener:
+ def open(self, request, timeout): return Response(b'abc')
+ monkeypatch.setattr(u.urllib.request, 'build_opener', lambda *args: Opener())
+ with pytest.raises(u.UpdateError): service.check_for_update('0.9.0')
+ def fail(*args): raise TimeoutError('timeout')
+ monkeypatch.setattr(service, '_fetch', fail)
+ with pytest.raises(u.UpdateError): service.check_for_update('0.9.0')
+
+
+def test_dependency_commands_have_clean_environment_and_fail_safely(service, tmp_path, monkeypatch):
+ import sys
+ monkeypatch.setenv('OPENAI_API_KEY', 'test-only-secret')
+ monkeypatch.setenv('PIP_EXTRA_INDEX_URL', 'https://example.invalid')
+ service._command([sys.executable, '-c',
+ 'import os, pathlib; assert "OPENAI_API_KEY" not in os.environ; '
+ 'assert "PIP_EXTRA_INDEX_URL" not in os.environ; '
+ 'pathlib.Path("ran").write_text("yes")'], tmp_path, 10)
+ assert (tmp_path / 'ran').read_text() == 'yes'
+ with pytest.raises(u.UpdateError):
+ service._command([sys.executable, '-c', 'raise SystemExit(1)'], tmp_path, 10)
+ with pytest.raises(u.UpdateError, match='timed out'):
+ service._command([sys.executable, '-c', 'import time; time.sleep(20)'], tmp_path, 0.05)
+
+
+def test_provision_never_uses_existing_environment(service, tmp_path, monkeypatch):
+ """Run real venv creation, replace only network-dependent pip/import commands."""
+ path = tmp_path / 'new-release'; path.mkdir()
+ real_command = service._command
+ calls = []
+ def offline(args, cwd, timeout):
+ if 'venv' in args:
+ real_command(args, cwd, timeout)
+ else:
+ # Every mutating pip command must point into the new environment.
+ assert args[0] == str(path / '.venv/bin/python')
+ calls.append(args)
+ monkeypatch.setattr(service, '_command', offline)
+ service._provision(path, dict(torch='torch', dependencies=['PyQt6', 'openai']))
+ assert (path / '.venv/pyvenv.cfg').is_file()
+ assert any('https://download.pytorch.org/whl/cpu' in args for args in calls)
+ assert any('PyQt6' in args and 'openai' in args for args in calls)
+ assert any('check' in args for args in calls)
+
+
+def test_build_install_round_trip(service, tmp_path, monkeypatch):
+ from scripts.build_release import build_release
+ source = Path(__file__).resolve().parents[1]
+ from app import __version__
+ package = build_release(source, tmp_path / 'dist', 'v' + __version__)
+ install_fixture(service, monkeypatch, package.read_bytes())
+ url = f'https://github.com/TimInTech/blitztext-linux/releases/download/v{__version__}/{package.name}'
+ path = service.install_release(u.Release(__version__, url, hashlib.sha256(package.read_bytes()).hexdigest()))
+ assert (path / 'app/update_service.py').is_file()
+ assert (path / 'run.sh').stat().st_mode & 0o111
+
+
+def test_gzip_expansion_is_bounded_before_tar_metadata(service, monkeypatch):
+ import gzip
+ # A huge PAX header must not bypass the member-size check inside tarfile.
+ monkeypatch.setattr(u, 'MAX_EXPANDED', 1024)
+ info = tarfile.TarInfo('metadata')
+ info.type = tarfile.XHDTYPE
+ info.size = 10 * 1024 * 1024
+ payload = gzip.compress(info.tobuf() + b' ' * info.size)
+ release = install_fixture(service, monkeypatch, payload)
+ with pytest.raises(u.UpdateError, match='limit'):
+ service.install_release(release)
+
+
+def test_constructor_does_not_create_data_directory(tmp_path, monkeypatch):
+ data = tmp_path / 'not-created'
+ monkeypatch.setenv('XDG_DATA_HOME', str(data))
+ u.UpdateService()
+ assert not data.exists()
diff --git a/tests/test_update_widget.py b/tests/test_update_widget.py
new file mode 100644
index 0000000..e0f8401
--- /dev/null
+++ b/tests/test_update_widget.py
@@ -0,0 +1,132 @@
+"""Update UI behavior using a real background job and an isolated fake service."""
+import os
+import threading
+import time
+from types import SimpleNamespace
+
+import pytest
+
+pytestmark = pytest.mark.skipif(os.environ.get('WHISPER_GUI_TESTS') != '1', reason='GUI tests')
+
+@pytest.fixture
+def qapp():
+ from PyQt6.QtWidgets import QApplication
+ return QApplication.instance() or QApplication([])
+
+
+def drain(qapp, predicate):
+ deadline = time.monotonic() + 3
+ while not predicate() and time.monotonic() < deadline:
+ qapp.processEvents()
+ time.sleep(.005)
+ assert predicate()
+
+
+class Service:
+ def __init__(self):
+ self.release = SimpleNamespace(version='9.0.0')
+ self.calls = []
+ self.error = None
+
+ def check_for_update(self, version):
+ self.calls.append(('check', threading.get_ident()))
+ if self.error:
+ raise RuntimeError(self.error)
+ return self.release
+
+ def install_release(self, release):
+ self.calls.append(('install', threading.get_ident()))
+ return '/isolated/release'
+
+
+def test_check_then_install_runs_off_gui_thread(qapp):
+ from app.update_widget import UpdateWidget
+ service = Service()
+ widget = UpdateWidget(service=service)
+ assert not widget.install_button.isEnabled()
+ widget.check_button.click()
+ drain(qapp, lambda: widget.install_button.isEnabled())
+ assert '9.0.0' in widget.status_label.text()
+ widget.install_button.click()
+ drain(qapp, lambda: widget._job is None)
+ assert [call[0] for call in service.calls] == ['check', 'install']
+ assert all(call[1] != threading.get_ident() for call in service.calls)
+ assert not widget.install_button.isEnabled()
+ assert not widget.check_button.isEnabled()
+ assert '9.0.0' in widget.status_label.text()
+ widget.deleteLater()
+
+
+def test_check_failure_allows_retry_without_stale_install(qapp):
+ from app.update_widget import UpdateWidget
+ service = Service()
+ widget = UpdateWidget(service=service)
+ widget.check_button.click()
+ drain(qapp, lambda: widget.install_button.isEnabled())
+ service.error = 'offline'
+ widget.check_button.click()
+ drain(qapp, lambda: widget._job is None)
+ assert widget.check_button.isEnabled()
+ assert not widget.install_button.isEnabled()
+ assert 'offline' in widget.status_label.text()
+ widget.deleteLater()
+
+
+def test_latest_version_does_not_offer_install(qapp):
+ from app.update_widget import UpdateWidget
+ service = Service()
+ service.release = None
+ widget = UpdateWidget(service=service)
+ widget.check_button.click()
+ drain(qapp, lambda: widget._job is None)
+ assert not widget.install_button.isEnabled()
+ assert widget.check_button.isEnabled()
+ widget.deleteLater()
+
+
+def test_destroy_widget_during_job_is_safe(qapp):
+ from PyQt6 import sip
+ from app.update_widget import UpdateWidget
+ started, release, ended = threading.Event(), threading.Event(), threading.Event()
+ class SlowService(Service):
+ def check_for_update(self, version):
+ started.set()
+ release.wait(2)
+ ended.set()
+ return None
+ widget = UpdateWidget(service=SlowService())
+ widget.check_button.click()
+ assert started.wait(1)
+ sip.delete(widget)
+ release.set()
+ drain(qapp, ended.is_set)
+ qapp.processEvents()
+
+
+def test_reopened_widget_shares_running_install_and_result(qapp):
+ from PyQt6 import sip
+ from app.update_widget import UpdateWidget
+ started, finish = threading.Event(), threading.Event()
+ class SlowService(Service):
+ def install_release(self, release):
+ self.calls.append(('install', threading.get_ident()))
+ started.set()
+ finish.wait(2)
+ return '/isolated/release'
+ service = SlowService()
+ first = UpdateWidget(service=service)
+ first.check_button.click()
+ drain(qapp, lambda: first.install_button.isEnabled())
+ first.install_button.click()
+ assert started.wait(1)
+ controller = first._controller
+ sip.delete(first)
+ second = UpdateWidget(controller=controller)
+ assert not second.check_button.isEnabled()
+ assert not second.install_button.isEnabled()
+ second.install_button.click()
+ finish.set()
+ drain(qapp, lambda: second._job is None)
+ assert [x[0] for x in service.calls].count('install') == 1
+ assert '9.0.0' in second.status_label.text()
+ second.deleteLater()