diff --git a/README.md b/README.md
index 9a9fb6b..d41b571 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,28 @@ folders and do not need the thumbnail grid immediately:
faststack --loupe "C:\path\to\photos"
```
+### Updating
+
+FastStack checks GitHub Releases for newer versions when update checks are
+enabled in Settings. For source or virtualenv installs, open the release page
+from FastStack and update from the checkout:
+
+```bash
+git pull
+venv/Scripts/python.exe -m pip install -e .
+```
+
+On Linux or macOS, use the Python executable from the active virtualenv:
+
+```bash
+git pull
+python -m pip install -e .
+```
+
+Automatic installation is intentionally disabled for source/virtualenv installs
+because a running Python app cannot reliably replace its own environment across
+Windows, Linux, and macOS.
+
### Command Line Options
```text
diff --git a/faststack/__main__.py b/faststack/__main__.py
index 5f12586..15c6220 100644
--- a/faststack/__main__.py
+++ b/faststack/__main__.py
@@ -1,4 +1,4 @@
-from .app import cli
+from faststack.app import cli
if __name__ == "__main__":
cli()
diff --git a/faststack/app.py b/faststack/app.py
index 060b4e8..7b2f109 100644
--- a/faststack/app.py
+++ b/faststack/app.py
@@ -10,7 +10,7 @@
import argparse
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple, Set
-from datetime import date, datetime
+from datetime import date, datetime, timezone
import os
import re
import shutil
@@ -30,7 +30,7 @@
import subprocess
from faststack.ui.provider import ImageProvider, UIState
import PySide6
-from PySide6.QtGui import QDrag, QPixmap
+from PySide6.QtGui import QDesktopServices, QDrag, QPixmap
from PySide6.QtCore import (
QUrl,
QTimer,
@@ -87,6 +87,7 @@
from faststack.imaging.mask_engine import inverse_transform
from faststack.imaging.metadata import get_exif_data
from faststack.resources import faststack_qml_dir, pyside_qml_dir
+from faststack.updater import check_for_update, get_current_version
from faststack.thumbnail_view import (
DEFAULT_THUMBNAIL_CACHE_BYTES,
ThumbnailModel,
@@ -230,6 +231,7 @@ class ProgressReporter(QObject):
object
) # Signal for async delete completion (result dict from worker)
_exifBriefReady = Signal(object, str) # (cache_key, brief) from background thread
+ _updateCheckFinished = Signal(object) # Update check result from background thread
def __init__(
self,
@@ -291,6 +293,12 @@ def __init__(
self._editor_prewarm_executor = create_daemon_threadpool_executor(
max_workers=1, thread_name_prefix="EditPrewarm"
)
+ self._update_executor = create_daemon_threadpool_executor(
+ max_workers=1, thread_name_prefix="UpdateCheck"
+ )
+ self._updateCheckFinished.connect(self._on_update_check_finished)
+ self._update_check_token = 0
+ self._update_check_inflight = False
self._preview_inflight = False
self._preview_pending = False
self._preview_token = 0
@@ -6205,6 +6213,173 @@ def save_config(self):
)
config.save()
+ @Slot(result=str)
+ def get_current_version(self):
+ return get_current_version()
+
+ @Slot(result=bool)
+ def get_update_check_enabled(self):
+ return config.getboolean("updates", "check_for_updates", True)
+
+ @Slot(bool)
+ def set_update_check_enabled(self, enabled):
+ config.set("updates", "check_for_updates", "true" if enabled else "false")
+ config.save()
+
+ @Slot(result=bool)
+ def get_auto_update_enabled(self):
+ return config.getboolean("updates", "auto_update", False)
+
+ @Slot(bool)
+ def set_auto_update_enabled(self, enabled):
+ # Source/venv installs cannot be safely self-updated yet. Persist false
+ # so the setting exists without promising unavailable behavior.
+ config.set("updates", "auto_update", "false")
+ config.save()
+
+ def _last_update_check_time(self) -> Optional[datetime]:
+ raw_value = config.get("updates", "last_check_at", fallback="").strip()
+ if not raw_value:
+ return None
+ try:
+ parsed = datetime.fromisoformat(raw_value)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+ @Slot()
+ def maybe_check_for_updates(self):
+ """Run the automatic update check if enabled and outside the cooldown."""
+ if not self.get_update_check_enabled():
+ return
+
+ last_check = self._last_update_check_time()
+ if last_check is not None:
+ elapsed = datetime.now(timezone.utc) - last_check
+ if elapsed.total_seconds() < 24 * 60 * 60:
+ return
+
+ self.check_for_updates(False)
+
+ @Slot(bool)
+ def check_for_updates(self, manual=False):
+ """Check GitHub Releases for a newer FastStack version."""
+ manual = bool(manual)
+ if self._shutting_down:
+ return
+ if self._update_check_inflight:
+ if manual:
+ self.update_status_message("Update check already running")
+ return
+ if not manual and not self.get_update_check_enabled():
+ return
+
+ self._update_check_inflight = True
+ self._update_check_token += 1
+ token = self._update_check_token
+ config.set(
+ "updates",
+ "last_check_at",
+ datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ )
+ config.save()
+
+ if manual:
+ self.update_status_message("Checking for updates...")
+
+ current_version = get_current_version()
+ try:
+ future = self._update_executor.submit(
+ check_for_update,
+ current_version=current_version,
+ )
+ except RuntimeError as e:
+ self._update_check_inflight = False
+ log.warning("Could not start update check: %s", e)
+ return
+
+ def _done(fut):
+ if self._shutting_down:
+ return
+ try:
+ payload = fut.result().to_qml_dict()
+ payload["error"] = ""
+ except Exception as e:
+ log.warning("Update check failed: %s", e)
+ payload = {
+ "currentVersion": current_version,
+ "latestVersion": "",
+ "releaseUrl": "",
+ "summary": "",
+ "isNewer": False,
+ "error": str(e),
+ }
+ payload["manual"] = manual
+ payload["token"] = token
+ if self._shutting_down:
+ return
+ self._updateCheckFinished.emit(payload)
+
+ future.add_done_callback(_done)
+
+ @Slot(object)
+ def _on_update_check_finished(self, payload):
+ if self._shutting_down:
+ return
+ if int(payload.get("token", -1)) != self._update_check_token:
+ return
+
+ self._update_check_inflight = False
+ manual = bool(payload.get("manual", False))
+ error = str(payload.get("error") or "")
+ if error:
+ if manual:
+ self.update_status_message(
+ f"Could not check for updates: {error}", 6000
+ )
+ return
+
+ current_version = str(payload.get("currentVersion") or get_current_version())
+ latest_version = str(payload.get("latestVersion") or "")
+ if payload.get("isNewer"):
+ ignored_version = config.get(
+ "updates", "last_ignored_version", fallback=""
+ ).strip()
+ if not manual and latest_version == ignored_version:
+ log.info("Update %s is available but skipped by user.", latest_version)
+ return
+
+ if self.main_window and hasattr(self.main_window, "openUpdateDialog"):
+ self.main_window.openUpdateDialog(payload)
+ else:
+ self.update_status_message(
+ f"FastStack {latest_version} is available", 8000
+ )
+ return
+
+ if manual:
+ self.update_status_message(f"FastStack {current_version} is up to date")
+
+ @Slot(str)
+ def skip_update_version(self, version):
+ version = str(version or "").strip()
+ if not version:
+ return
+ config.set("updates", "last_ignored_version", version)
+ config.save()
+ self.update_status_message(f"Skipped FastStack {version}")
+
+ @Slot(str)
+ def open_update_release(self, url):
+ url = str(url or "").strip()
+ if not url:
+ self.update_status_message("No update release URL available")
+ return
+ if not QDesktopServices.openUrl(QUrl(url)):
+ self.update_status_message("Could not open update release page", 5000)
+
@Slot(result=str)
def get_color_mode(self):
"""Returns current color management mode: 'none', 'saturation', or 'icc'."""
@@ -8554,6 +8729,11 @@ def shutdown_nonqt(self):
"editor prewarm",
wait=False,
)
+ self._safe_shutdown_executor(
+ getattr(self, "_update_executor", None),
+ "update",
+ wait=False,
+ )
# wait=True ensures pending saves/deletes complete to avoid data loss/corruption
self._safe_shutdown_executor(
self._save_executor, "save", wait=True, cancel_futures=False
@@ -12397,6 +12577,7 @@ def main(
0,
lambda: controller.load(skip_thumbnail_refresh=start_in_loupe),
)
+ QTimer.singleShot(2000, controller.maybe_check_for_updates)
if debug:
log.info(
"Startup: controller.load() deferred to event loop (%.3fs to window)",
diff --git a/faststack/config.py b/faststack/config.py
index 1ef8752..c60cbd9 100644
--- a/faststack/config.py
+++ b/faststack/config.py
@@ -125,6 +125,12 @@ def version_sort_key(path):
"source_dir": "C:\\Users\\alanr\\pictures\\olympus.stack.input.photos",
"mirror_base": "C:\\Users\\alanr\\Pictures\\Lightroom",
},
+ "updates": {
+ "check_for_updates": "true",
+ "auto_update": "false",
+ "last_check_at": "",
+ "last_ignored_version": "",
+ },
}
diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml
index 65f6c93..975daa2 100644
--- a/faststack/qml/Main.qml
+++ b/faststack/qml/Main.qml
@@ -161,6 +161,11 @@ ApplicationWindow {
root.openDialogSafely(colorInfoDialog)
}
+ function openUpdateDialog(info) {
+ updateDialog.updateInfo = info
+ root.openDialogSafely(updateDialog)
+ }
+
function setGridPrefetch(item, enabled) {
var methodName = "set" + "PrefetchEnabled"
var setter = item ? item[methodName] : null
@@ -1217,6 +1222,16 @@ ApplicationWindow {
defaultTextColor: root.currentTextColor
onClicked: { root.openDialogSafely(aboutDialog); helpMenu.close() }
}
+ MenuActionItem {
+ width: 200
+ text: "Check for Updates"
+ hoverFillColor: root.menuHoverColor
+ defaultTextColor: root.currentTextColor
+ onClicked: {
+ if (root.controllerRef) root.controllerRef.check_for_updates(true)
+ helpMenu.close()
+ }
+ }
}
}
@@ -1838,7 +1853,8 @@ ApplicationWindow {
// Column 1
Text {
width: 450
- text: "FastStack Keyboard and Mouse Commands
" +
+ text: "FastStack " + (root.uiStateRef ? root.uiStateRef.get_current_version() : "") + "
" +
+ "Keyboard and Mouse Commands
" +
"Navigation:
" +
" Right Arrow: Next Image
" +
" Left Arrow: Previous Image
" +
@@ -2052,6 +2068,124 @@ ApplicationWindow {
textColor: root.currentTextColor
}
+ Dialog {
+ id: updateDialog
+ title: "Update Available"
+ modal: true
+ standardButtons: Dialog.NoButton
+ closePolicy: Popup.CloseOnEscape
+ width: Math.min(580, parent ? parent.width * 0.9 : 580)
+ height: Math.min(520, parent ? parent.height * 0.86 : 520)
+ x: parent ? (parent.width - width) / 2 : 0
+ y: parent ? (parent.height - height) / 2 : 0
+ padding: 18
+
+ property var updateInfo: ({})
+ readonly property string latestVersion: updateInfo && updateInfo.latestVersion ? updateInfo.latestVersion : ""
+ readonly property string currentVersion: updateInfo && updateInfo.currentVersion ? updateInfo.currentVersion : ""
+ readonly property string releaseUrl: updateInfo && updateInfo.releaseUrl ? updateInfo.releaseUrl : ""
+ readonly property string releaseSummary: updateInfo && updateInfo.summary ? updateInfo.summary : "Open the release page for details."
+
+ onOpened: {
+ if (root.controllerRef) root.controllerRef.dialog_opened()
+ }
+ onClosed: {
+ if (root.controllerRef) root.controllerRef.dialog_closed()
+ }
+
+ background: Rectangle {
+ color: root.isDarkTheme ? "#1e1e1e" : "#fdfdfd"
+ border.color: root.isDarkTheme ? "#444444" : "#dddddd"
+ border.width: 1
+ radius: 8
+ }
+
+ contentItem: ColumnLayout {
+ spacing: 14
+
+ Label {
+ Layout.fillWidth: true
+ text: "FastStack " + updateDialog.latestVersion + " is available"
+ color: root.currentTextColor
+ font.pixelSize: 20
+ font.bold: true
+ wrapMode: Text.WordWrap
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: "Installed version: " + updateDialog.currentVersion
+ color: root.isDarkTheme ? "#bbbbbb" : "#555555"
+ font.pixelSize: 13
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: root.isDarkTheme ? "#151515" : "#f4f4f4"
+ border.color: root.isDarkTheme ? "#333333" : "#dddddd"
+ border.width: 1
+ radius: 6
+
+ ScrollView {
+ anchors.fill: parent
+ anchors.margins: 10
+ clip: true
+
+ TextArea {
+ text: updateDialog.releaseSummary
+ color: root.currentTextColor
+ readOnly: true
+ selectByMouse: true
+ wrapMode: Text.WordWrap
+ font.pixelSize: 13
+ background: Rectangle { color: "transparent" }
+ }
+ }
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: "FastStack will open the GitHub release page so you can update your source or virtualenv install."
+ color: root.isDarkTheme ? "#bbbbbb" : "#555555"
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 10
+
+ Button {
+ text: "Skip This Version"
+ Layout.preferredWidth: 150
+ onClicked: {
+ if (root.controllerRef) root.controllerRef.skip_update_version(updateDialog.latestVersion)
+ updateDialog.close()
+ }
+ }
+
+ Item { Layout.fillWidth: true }
+
+ Button {
+ text: "Remind Me Later"
+ Layout.preferredWidth: 140
+ onClicked: updateDialog.close()
+ }
+
+ Button {
+ text: "Open Release"
+ highlighted: true
+ Layout.preferredWidth: 130
+ onClicked: {
+ if (root.controllerRef) root.controllerRef.open_update_release(updateDialog.releaseUrl)
+ updateDialog.close()
+ }
+ }
+ }
+ }
+ }
+
// Debug Cache Indicator (Yellow Square)
Rectangle {
id: debugIndicator
diff --git a/faststack/qml/SettingsDialog.qml b/faststack/qml/SettingsDialog.qml
index eb753bb..5f717ae 100644
--- a/faststack/qml/SettingsDialog.qml
+++ b/faststack/qml/SettingsDialog.qml
@@ -38,6 +38,8 @@ Window {
property string photoshopPath: ""
property string rawtherapeePath: ""
property string optimizeFor: "speed"
+ property bool updateCheckEnabled: true
+ property bool autoUpdateEnabled: false
property string awbMode: "lab"
property double awbStrength: 0.7
@@ -142,6 +144,8 @@ Window {
settingsDialog.theme = settingsDialog.uiStateRef.theme
settingsDialog.defaultDirectory = settingsDialog.uiStateRef.get_default_directory()
settingsDialog.optimizeFor = settingsDialog.uiStateRef.get_optimize_for()
+ settingsDialog.updateCheckEnabled = settingsDialog.uiStateRef.get_update_check_enabled()
+ settingsDialog.autoUpdateEnabled = settingsDialog.uiStateRef.get_auto_update_enabled()
settingsDialog.autoLevelClippingThreshold = settingsDialog.uiStateRef.autoLevelClippingThreshold
settingsDialog.autoLevelStrength = settingsDialog.uiStateRef.autoLevelStrength
settingsDialog.autoLevelStrengthAuto = settingsDialog.uiStateRef.autoLevelStrengthAuto
@@ -201,6 +205,8 @@ Window {
state.set_theme(settingsDialog.theme)
state.set_default_directory(settingsDialog.defaultDirectory)
state.set_optimize_for(settingsDialog.optimizeFor)
+ state.set_update_check_enabled(settingsDialog.updateCheckEnabled)
+ state.set_auto_update_enabled(settingsDialog.autoUpdateEnabled)
state.autoLevelClippingThreshold = settingsDialog.autoLevelClippingThreshold
state.autoLevelStrength = settingsDialog.autoLevelStrength
state.autoLevelStrengthAuto = settingsDialog.autoLevelStrengthAuto
@@ -755,6 +761,83 @@ Window {
background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 }
}
}
+
+ Loader { sourceComponent: sectionSeparator }
+
+ Loader {
+ sourceComponent: sectionHeader
+ onLoaded: item.text = "Updates"
+ }
+
+ GridLayout {
+ columns: 2
+ columnSpacing: 20
+ rowSpacing: 12
+ Layout.fillWidth: true
+
+ Label {
+ text: "Check for Updates"
+ color: settingsDialog.textColor
+ }
+ CheckBox {
+ id: updateCheckBox
+ text: "Enabled"
+ checked: settingsDialog.updateCheckEnabled
+ onCheckedChanged: settingsDialog.updateCheckEnabled = checked
+ contentItem: Text { text: updateCheckBox.text; color: settingsDialog.textColor; leftPadding: updateCheckBox.indicator.width + updateCheckBox.spacing; verticalAlignment: Text.AlignVCenter }
+ indicator: Rectangle {
+ implicitWidth: 18; implicitHeight: 18
+ x: updateCheckBox.leftPadding; y: parent.height / 2 - height / 2
+ radius: 3
+ border.color: settingsDialog.accentColor
+ color: updateCheckBox.checked ? settingsDialog.accentColor : "transparent"
+ Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: updateCheckBox.checked; font.bold: true }
+ }
+ }
+
+ Label {
+ text: "Install Updates Automatically"
+ color: settingsDialog.textColor
+ opacity: 0.55
+ }
+ CheckBox {
+ id: autoUpdateBox
+ text: "Unavailable"
+ enabled: false
+ checked: settingsDialog.autoUpdateEnabled
+ opacity: 0.55
+ hoverEnabled: true
+ ToolTip.visible: hovered
+ ToolTip.delay: 500
+ ToolTip.text: "Automatic installation is disabled for source and virtualenv installs. FastStack can open the GitHub release page instead."
+ contentItem: Text { text: autoUpdateBox.text; color: settingsDialog.textColor; leftPadding: autoUpdateBox.indicator.width + autoUpdateBox.spacing; verticalAlignment: Text.AlignVCenter }
+ indicator: Rectangle {
+ implicitWidth: 18; implicitHeight: 18
+ x: autoUpdateBox.leftPadding; y: parent.height / 2 - height / 2
+ radius: 3
+ border.color: settingsDialog.controlBorder
+ color: "transparent"
+ }
+ }
+
+ Label {
+ text: "Manual Check"
+ color: settingsDialog.textColor
+ }
+ Button {
+ id: updateCheckNowButton
+ text: "Check Now"
+ flat: true
+ onClicked: {
+ if (settingsDialog.controllerRef) {
+ settingsDialog.visible = false
+ settingsDialog.controllerRef.check_for_updates(true)
+ }
+ }
+ background: Rectangle { color: updateCheckNowButton.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 }
+ contentItem: Text { text: updateCheckNowButton.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter }
+ }
+ }
Item { Layout.fillHeight: true } // Spacer
}
diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py
index e33055d..e70d563 100644
--- a/faststack/ui/provider.py
+++ b/faststack/ui/provider.py
@@ -1091,6 +1091,30 @@ def set_optimize_for(self, optimize_for):
def open_directory_dialog(self):
return self.app_controller.open_directory_dialog()
+ @Slot(result=str)
+ def get_current_version(self):
+ return self.app_controller.get_current_version()
+
+ @Slot(result=bool)
+ def get_update_check_enabled(self):
+ return self.app_controller.get_update_check_enabled()
+
+ @Slot(bool)
+ def set_update_check_enabled(self, enabled):
+ self.app_controller.set_update_check_enabled(enabled)
+
+ @Slot(result=bool)
+ def get_auto_update_enabled(self):
+ return self.app_controller.get_auto_update_enabled()
+
+ @Slot(bool)
+ def set_auto_update_enabled(self, enabled):
+ self.app_controller.set_auto_update_enabled(enabled)
+
+ @Slot()
+ def check_for_updates(self):
+ self.app_controller.check_for_updates(True)
+
@Property(float, notify=autoLevelClippingThresholdChanged)
def autoLevelClippingThreshold(self):
return self.app_controller.get_auto_level_clipping_threshold()
diff --git a/faststack/updater.py b/faststack/updater.py
new file mode 100644
index 0000000..7183250
--- /dev/null
+++ b/faststack/updater.py
@@ -0,0 +1,203 @@
+"""GitHub release update checks for FastStack."""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import tomllib
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from importlib import metadata
+from pathlib import Path
+from typing import Any
+
+try:
+ from packaging.version import InvalidVersion, Version
+except ImportError: # pragma: no cover - dependency fallback for stale dev envs
+ InvalidVersion = ValueError
+ Version = None
+
+log = logging.getLogger(__name__)
+
+GITHUB_REPOSITORY = "AlanRockefeller/faststack"
+LATEST_RELEASE_URL = f"https://api.github.com/repos/{GITHUB_REPOSITORY}/releases/latest"
+USER_AGENT = "FastStack Update Checker"
+FALLBACK_VERSION = "1.6.4"
+
+
+class UpdateCheckError(RuntimeError):
+ """Raised when an update check cannot be completed."""
+
+
+@dataclass(frozen=True)
+class UpdateInfo:
+ current_version: str
+ latest_version: str
+ tag_name: str
+ release_name: str
+ release_url: str
+ published_at: str
+ summary: str
+ body: str
+ asset_names: tuple[str, ...]
+ is_newer: bool
+
+ def to_qml_dict(self) -> dict[str, Any]:
+ return {
+ "currentVersion": self.current_version,
+ "latestVersion": self.latest_version,
+ "tagName": self.tag_name,
+ "releaseName": self.release_name,
+ "releaseUrl": self.release_url,
+ "publishedAt": self.published_at,
+ "summary": self.summary,
+ "body": self.body,
+ "assetNames": list(self.asset_names),
+ "isNewer": self.is_newer,
+ }
+
+
+def get_current_version() -> str:
+ """Return the installed FastStack version.
+
+ Installed packages expose metadata. Running directly from a source checkout
+ usually does not, so fall back to pyproject.toml and then a release-time
+ constant as a last resort for frozen builds.
+ """
+ try:
+ return metadata.version("faststack")
+ except metadata.PackageNotFoundError:
+ pass
+
+ pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
+ try:
+ with pyproject_path.open("rb") as f:
+ data = tomllib.load(f)
+ version = data.get("project", {}).get("version")
+ if isinstance(version, str) and version.strip():
+ return version.strip()
+ except (OSError, tomllib.TOMLDecodeError):
+ log.debug("Could not read version from %s", pyproject_path, exc_info=True)
+
+ return FALLBACK_VERSION
+
+
+def normalize_version(version: str) -> str:
+ """Normalize common release tag forms for comparison."""
+ value = version.strip()
+ if value.startswith(("v", "V")):
+ value = value[1:]
+ return value
+
+
+def is_newer_version(latest: str, current: str) -> bool:
+ """Return True when latest is newer than current."""
+ if Version is None:
+ return _fallback_version_key(latest) > _fallback_version_key(current)
+
+ try:
+ return Version(normalize_version(latest)) > Version(normalize_version(current))
+ except InvalidVersion:
+ log.warning(
+ "Could not parse update versions: latest=%r current=%r",
+ latest,
+ current,
+ )
+ return False
+
+
+def _fallback_version_key(version: str) -> tuple[int, ...]:
+ """Best-effort numeric comparison when packaging is unavailable."""
+ parts = re.findall(r"\d+", normalize_version(version))
+ if not parts:
+ return (0,)
+ return tuple(int(part) for part in parts)
+
+
+def summarize_release_body(body: str, limit: int = 900) -> str:
+ """Return a compact summary suitable for the in-app update dialog."""
+ lines: list[str] = []
+ for raw_line in body.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+ lines.append(line)
+ if len("\n".join(lines)) >= limit or len(lines) >= 10:
+ break
+
+ summary = "\n".join(lines).strip()
+ if len(summary) > limit:
+ summary = summary[: limit - 3].rstrip() + "..."
+ return summary
+
+
+def fetch_latest_release(timeout: float = 5.0) -> dict[str, Any]:
+ """Fetch the latest non-prerelease GitHub release payload."""
+ request = urllib.request.Request(
+ LATEST_RELEASE_URL,
+ headers={
+ "Accept": "application/vnd.github+json",
+ "User-Agent": USER_AGENT,
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ )
+
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ if getattr(response, "status", 200) >= 400:
+ raise UpdateCheckError(f"GitHub returned HTTP {response.status}")
+ return json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ raise UpdateCheckError(f"GitHub returned HTTP {e.code}") from e
+ except urllib.error.URLError as e:
+ raise UpdateCheckError(f"Could not reach GitHub: {e.reason}") from e
+ except TimeoutError as e:
+ raise UpdateCheckError("GitHub update check timed out") from e
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
+ raise UpdateCheckError("GitHub returned an invalid release response") from e
+
+
+def check_for_update(
+ *,
+ current_version: str | None = None,
+ timeout: float = 5.0,
+) -> UpdateInfo:
+ """Check GitHub Releases and return normalized update information."""
+ current = current_version or get_current_version()
+ payload = fetch_latest_release(timeout=timeout)
+ if not isinstance(payload, dict):
+ raise UpdateCheckError(
+ f"GitHub returned an unexpected release payload shape: "
+ f"{type(payload).__name__}"
+ )
+
+ tag_name = str(payload.get("tag_name") or "").strip()
+ latest_version = normalize_version(tag_name)
+ if not latest_version:
+ raise UpdateCheckError("Latest GitHub release did not include a tag")
+
+ release_name = str(payload.get("name") or tag_name)
+ release_url = str(payload.get("html_url") or "")
+ published_at = str(payload.get("published_at") or "")
+ body = str(payload.get("body") or "")
+ assets = payload.get("assets") or []
+ asset_names = tuple(
+ str(asset.get("name"))
+ for asset in assets
+ if isinstance(asset, dict) and asset.get("name")
+ )
+
+ return UpdateInfo(
+ current_version=current,
+ latest_version=latest_version,
+ tag_name=tag_name,
+ release_name=release_name,
+ release_url=release_url,
+ published_at=published_at,
+ summary=summarize_release_body(body),
+ body=body,
+ asset_names=asset_names,
+ is_newer=is_newer_version(latest_version, current),
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 7900c4c..76d2b54 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,7 @@ dependencies = [
"cachetools>=5.0,<6.0",
"watchdog>=4.0,<5.0",
"Pillow>=10.0,<11.0",
+ "packaging>=24.0,<26.0",
# OpenCV 4.10+ might be required for NumPy 2.0 binary compatibility
"opencv-python>=4.10.0,<5.0",
]
diff --git a/requirements.txt b/requirements.txt
index 8688bc5..83bea6f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,4 +3,4 @@ numpy==2.*
cachetools==5.*
watchdog==4.*
Pillow==10.* # fallback decode; keep it
-
+packaging>=24,<26