Improve moving unchecked TODOs to next weekday and from last 7 days. New version checker. Remove newline after headings
This commit is contained in:
parent
ab0a9400c9
commit
5bf6d4c4d6
13 changed files with 701 additions and 70 deletions
386
bouquin/version_check.py
Normal file
386
bouquin/version_check.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from importlib.resources import files
|
||||
from PySide6.QtCore import QStandardPaths, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMessageBox,
|
||||
QWidget,
|
||||
QProgressDialog,
|
||||
)
|
||||
|
||||
from .settings import APP_NAME
|
||||
from . import strings
|
||||
|
||||
|
||||
# Where to fetch the latest version string from
|
||||
VERSION_URL = "https://mig5.net/bouquin/version.txt"
|
||||
|
||||
# Name of the installed distribution according to pyproject.toml
|
||||
# (used with importlib.metadata.version)
|
||||
DIST_NAME = "bouquin"
|
||||
|
||||
# Base URL where AppImages are hosted
|
||||
APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download"
|
||||
|
||||
# Where we expect to find the bundled public key, relative to the *installed* package.
|
||||
GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc")
|
||||
|
||||
|
||||
class VersionChecker:
|
||||
"""
|
||||
Handles:
|
||||
* showing the version dialog
|
||||
* checking for updates
|
||||
* downloading & verifying a new AppImage
|
||||
|
||||
All dialogs use `parent` as their parent widget.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
self._parent = parent
|
||||
|
||||
# ---------- Version helpers ---------- #
|
||||
|
||||
def current_version(self) -> str:
|
||||
"""
|
||||
Return the current app version as reported by importlib.metadata
|
||||
"""
|
||||
try:
|
||||
return importlib.metadata.version(DIST_NAME)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
# Fallback for editable installs / dev trees
|
||||
return "0.0.0"
|
||||
|
||||
@staticmethod
|
||||
def _parse_version(v: str) -> tuple[int, ...]:
|
||||
"""
|
||||
Very small helper to compare simple semantic versions like 1.2.3.
|
||||
Extracts numeric components and returns them as a tuple.
|
||||
"""
|
||||
parts = re.findall(r"\d+", v)
|
||||
if not parts:
|
||||
return (0,)
|
||||
return tuple(int(p) for p in parts)
|
||||
|
||||
def _is_newer_version(self, available: str, current: str) -> bool:
|
||||
"""
|
||||
True if `available` > `current` according to _parse_version.
|
||||
"""
|
||||
return self._parse_version(available) > self._parse_version(current)
|
||||
|
||||
# ---------- Public entrypoint for Help → Version ---------- #
|
||||
|
||||
def show_version_dialog(self) -> None:
|
||||
"""
|
||||
Show the Version dialog with a 'Check for updates' button.
|
||||
"""
|
||||
version = self.current_version()
|
||||
version_formatted = f"{APP_NAME} {version}"
|
||||
|
||||
box = QMessageBox(self._parent)
|
||||
box.setIcon(QMessageBox.Information)
|
||||
box.setWindowTitle(strings._("version"))
|
||||
box.setText(version_formatted)
|
||||
|
||||
check_button = box.addButton(
|
||||
strings._("check_for_updates"), QMessageBox.ActionRole
|
||||
)
|
||||
box.addButton(QMessageBox.Close)
|
||||
|
||||
box.exec()
|
||||
|
||||
if box.clickedButton() is check_button:
|
||||
self.check_for_updates()
|
||||
|
||||
# ---------- Core update logic ---------- #
|
||||
|
||||
def check_for_updates(self) -> None:
|
||||
"""
|
||||
Fetch VERSION_URL, compare against the current version, and optionally
|
||||
download + verify a new AppImage.
|
||||
"""
|
||||
current = self.current_version()
|
||||
|
||||
try:
|
||||
resp = requests.get(VERSION_URL, timeout=10)
|
||||
resp.raise_for_status()
|
||||
available_raw = resp.text.strip()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_check_for_updates") + e,
|
||||
)
|
||||
return
|
||||
|
||||
if not available_raw:
|
||||
QMessageBox.warning(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("update_server_returned_an_empty_version_string"),
|
||||
)
|
||||
return
|
||||
|
||||
if not self._is_newer_version(available_raw, current):
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("you_are_running_the_latest_version") + f"({current}).",
|
||||
)
|
||||
return
|
||||
|
||||
# Newer version is available
|
||||
reply = QMessageBox.question(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
(
|
||||
strings._("there_is_a_new_version_available")
|
||||
+ available_raw
|
||||
+ "\n\n"
|
||||
+ strings._("download_the_appimage")
|
||||
),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
self._download_and_verify_appimage(available_raw)
|
||||
|
||||
# ---------- Download + verification helpers ---------- #
|
||||
def _download_file(
|
||||
self,
|
||||
url: str,
|
||||
dest_path: Path,
|
||||
timeout: int = 30,
|
||||
progress: QProgressDialog | None = None,
|
||||
label: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Stream a URL to a local file, optionally updating a QProgressDialog.
|
||||
If the user cancels via the dialog, raises RuntimeError.
|
||||
"""
|
||||
resp = requests.get(url, timeout=timeout, stream=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total_bytes: int | None = None
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
total_bytes = int(content_length)
|
||||
except ValueError:
|
||||
total_bytes = None
|
||||
|
||||
if progress is not None:
|
||||
progress.setLabelText(
|
||||
label or strings._("downloading") + f" {dest_path.name}..."
|
||||
)
|
||||
# Unknown size → busy indicator; known size → real range
|
||||
if total_bytes is not None and total_bytes > 0:
|
||||
progress.setRange(0, total_bytes)
|
||||
else:
|
||||
progress.setRange(0, 0) # indeterminate
|
||||
progress.setValue(0)
|
||||
progress.show()
|
||||
QApplication.processEvents()
|
||||
|
||||
downloaded = 0
|
||||
with dest_path.open("wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if not chunk:
|
||||
continue
|
||||
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if progress is not None:
|
||||
if total_bytes is not None and total_bytes > 0:
|
||||
progress.setValue(downloaded)
|
||||
else:
|
||||
# Just bump a little so the dialog looks alive
|
||||
progress.setValue(progress.value() + 1)
|
||||
QApplication.processEvents()
|
||||
|
||||
if progress.wasCanceled():
|
||||
raise RuntimeError(strings._("download_cancelled"))
|
||||
|
||||
if progress is not None and total_bytes is not None and total_bytes > 0:
|
||||
progress.setValue(total_bytes)
|
||||
QApplication.processEvents()
|
||||
|
||||
def _download_and_verify_appimage(self, version: str) -> None:
|
||||
"""
|
||||
Download the AppImage + its GPG signature to the user's Downloads dir,
|
||||
then verify it with a bundled public key.
|
||||
"""
|
||||
# Where to put the file
|
||||
download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation)
|
||||
if not download_dir:
|
||||
download_dir = os.path.expanduser("~/Downloads")
|
||||
download_dir = Path(download_dir)
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Construct AppImage filename and URLs
|
||||
appimage_path = download_dir / "Bouquin.AppImage"
|
||||
sig_path = Path(str(appimage_path) + ".asc")
|
||||
|
||||
appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage"
|
||||
sig_url = f"{appimage_url}.asc"
|
||||
|
||||
# Progress dialog covering both downloads
|
||||
progress = QProgressDialog(
|
||||
"Downloading update...",
|
||||
"Cancel",
|
||||
0,
|
||||
100,
|
||||
self._parent,
|
||||
)
|
||||
progress.setWindowTitle(strings._("update"))
|
||||
progress.setWindowModality(Qt.WindowModal)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
|
||||
try:
|
||||
# AppImage download
|
||||
self._download_file(
|
||||
appimage_url,
|
||||
appimage_path,
|
||||
progress=progress,
|
||||
label=strings._("downloading") + " Bouquin.AppImage...",
|
||||
)
|
||||
# Signature download (usually tiny, but we still show it)
|
||||
self._download_file(
|
||||
sig_url,
|
||||
sig_path,
|
||||
progress=progress,
|
||||
label=strings._("downloading") + " signature...",
|
||||
)
|
||||
except RuntimeError:
|
||||
# User cancelled
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
progress.close()
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("download_cancelled"),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
# Other error
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
progress.close()
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("failed_to_download_update") + e,
|
||||
)
|
||||
return
|
||||
|
||||
progress.close()
|
||||
|
||||
# Load the bundled public key
|
||||
try:
|
||||
pkg, *rel = GPG_PUBKEY_RESOURCE
|
||||
pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_read_bundled_gpg_public_key") + e,
|
||||
)
|
||||
# On failure, delete the downloaded files for safety
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
# Use a temporary GNUPGHOME so we don't touch the user's main keyring
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as gnupg_home:
|
||||
pubkey_path = Path(gnupg_home) / "pubkey.asc"
|
||||
pubkey_path.write_bytes(pubkey_bytes)
|
||||
|
||||
# Import the key
|
||||
subprocess.run(
|
||||
["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
) # nosec
|
||||
|
||||
# Verify the signature
|
||||
subprocess.run(
|
||||
[
|
||||
"gpg",
|
||||
"--homedir",
|
||||
gnupg_home,
|
||||
"--verify",
|
||||
str(sig_path),
|
||||
str(appimage_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
) # nosec
|
||||
except FileNotFoundError:
|
||||
# gpg not installed / not on PATH
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("could_not_find_gpg_executable"),
|
||||
)
|
||||
return
|
||||
except subprocess.CalledProcessError as e:
|
||||
for p in (appimage_path, sig_path):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
QMessageBox.critical(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("gpg_signature_verification_failed")
|
||||
+ e.stderr.decode(errors="ignore"),
|
||||
)
|
||||
return
|
||||
|
||||
# Success
|
||||
QMessageBox.information(
|
||||
self._parent,
|
||||
strings._("update"),
|
||||
strings._("downloaded_and_verified_new_appimage") + appimage_path,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue