From 7ba02da29c0de4258b32943b83b5ac777e962f9a Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 16 Jun 2026 00:00:31 -0600 Subject: [PATCH 1/3] Add update notification --- README.md | 22 ++++ faststack/app.py | 174 ++++++++++++++++++++++++++- faststack/config.py | 6 + faststack/qml/Main.qml | 136 ++++++++++++++++++++- faststack/qml/SettingsDialog.qml | 82 +++++++++++++ faststack/ui/provider.py | 24 ++++ faststack/updater.py | 198 +++++++++++++++++++++++++++++++ pyproject.toml | 1 + requirements.txt | 2 +- 9 files changed, 641 insertions(+), 4 deletions(-) create mode 100644 faststack/updater.py diff --git a/README.md b/README.md index 9a9fb6b..8a4a8f7 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/app.py b/faststack/app.py index 060b4e8..85fc473 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,162 @@ 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._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() + future = self._update_executor.submit( + check_for_update, + current_version=current_version, + ) + + def _done(fut): + 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 + 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 +8718,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 +12566,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..1ac3f72 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,82 @@ 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: false + 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.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..e95b934 --- /dev/null +++ b/faststack/updater.py @@ -0,0 +1,198 @@ +"""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) + + 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 From 64fdcd896a8a4fcee036cfa2874add875b4977ff Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 16 Jun 2026 00:03:24 -0600 Subject: [PATCH 2/3] Fix PyInstaller entry point import --- faststack/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From 134c5fac5151954fa9f8bcc9600459637cbe9c22 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 16 Jun 2026 15:30:53 -0600 Subject: [PATCH 3/3] Fix minor bugs --- README.md | 2 +- faststack/app.py | 19 +++++++++++++++---- faststack/qml/SettingsDialog.qml | 3 ++- faststack/updater.py | 5 +++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8a4a8f7..d41b571 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ from FastStack and update from the checkout: ```bash git pull -.venv/Scripts/python.exe -m pip install -e . +venv/Scripts/python.exe -m pip install -e . ``` On Linux or macOS, use the Python executable from the active virtualenv: diff --git a/faststack/app.py b/faststack/app.py index 85fc473..7b2f109 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -6267,6 +6267,8 @@ def maybe_check_for_updates(self): 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") @@ -6288,12 +6290,19 @@ def check_for_updates(self, manual=False): self.update_status_message("Checking for updates...") current_version = get_current_version() - future = self._update_executor.submit( - check_for_update, - current_version=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"] = "" @@ -6309,6 +6318,8 @@ def _done(fut): } payload["manual"] = manual payload["token"] = token + if self._shutting_down: + return self._updateCheckFinished.emit(payload) future.add_done_callback(_done) diff --git a/faststack/qml/SettingsDialog.qml b/faststack/qml/SettingsDialog.qml index 1ac3f72..5f717ae 100644 --- a/faststack/qml/SettingsDialog.qml +++ b/faststack/qml/SettingsDialog.qml @@ -804,7 +804,7 @@ Window { id: autoUpdateBox text: "Unavailable" enabled: false - checked: false + checked: settingsDialog.autoUpdateEnabled opacity: 0.55 hoverEnabled: true ToolTip.visible: hovered @@ -830,6 +830,7 @@ Window { flat: true onClicked: { if (settingsDialog.controllerRef) { + settingsDialog.visible = false settingsDialog.controllerRef.check_for_updates(true) } } diff --git a/faststack/updater.py b/faststack/updater.py index e95b934..7183250 100644 --- a/faststack/updater.py +++ b/faststack/updater.py @@ -167,6 +167,11 @@ def check_for_update( """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)