diff --git a/src/Mod/Create/CMakeLists.txt b/src/Mod/Create/CMakeLists.txt index 40aab5df9c..de033db073 100644 --- a/src/Mod/Create/CMakeLists.txt +++ b/src/Mod/Create/CMakeLists.txt @@ -1,11 +1,20 @@ # Kindred Create core module # Handles auto-loading of ztools and Silo addons +# Generate version.py from template with Kindred Create version +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/version.py.in + ${CMAKE_CURRENT_BINARY_DIR}/version.py + @ONLY +) + # Install Python init files install( FILES Init.py InitGui.py + update_checker.py + ${CMAKE_CURRENT_BINARY_DIR}/version.py DESTINATION Mod/Create ) diff --git a/src/Mod/Create/InitGui.py b/src/Mod/Create/InitGui.py index c3da4592be..bdf0eed086 100644 --- a/src/Mod/Create/InitGui.py +++ b/src/Mod/Create/InitGui.py @@ -148,6 +148,16 @@ def _setup_silo_activity_panel(): FreeCAD.Console.PrintLog(f"Create: Silo activity panel skipped: {e}\n") +def _check_for_updates(): + """Check for application updates in the background.""" + try: + from update_checker import _run_update_check + + _run_update_check() + except Exception as e: + FreeCAD.Console.PrintLog(f"Create: Update check skipped: {e}\n") + + # Defer enhancements until the GUI event loop is running try: from PySide.QtCore import QTimer @@ -156,5 +166,6 @@ try: QTimer.singleShot(2000, _setup_silo_auth_panel) QTimer.singleShot(3000, _check_silo_first_start) QTimer.singleShot(4000, _setup_silo_activity_panel) + QTimer.singleShot(10000, _check_for_updates) except Exception: pass diff --git a/src/Mod/Create/update_checker.py b/src/Mod/Create/update_checker.py new file mode 100644 index 0000000000..eb402ab90a --- /dev/null +++ b/src/Mod/Create/update_checker.py @@ -0,0 +1,165 @@ +"""Kindred Create update checker. + +Queries the Gitea releases API to determine if a newer version is +available. Designed to run in the background on startup without +blocking the UI. +""" + +import json +import re +import urllib.request +from datetime import datetime, timezone + +import FreeCAD + +_RELEASES_URL = "https://git.kindred-systems.com/api/v1/repos/kindred/create/releases" +_PREF_PATH = "User parameter:BaseApp/Preferences/Mod/KindredCreate/Update" +_TIMEOUT = 5 + + +def _parse_version(tag): + """Parse a version tag like 'v0.1.3' into a comparable tuple. + + Returns None if the tag doesn't match the expected pattern. + """ + m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)$", tag) + if not m: + return None + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + + +def check_for_update(current_version): + """Check if a newer release is available on Gitea. + + Args: + current_version: Version string like "0.1.3". + + Returns: + Dict with update info if a newer version exists, None otherwise. + Dict keys: version, tag, release_url, assets, body. + """ + current = _parse_version(current_version) + if current is None: + return None + + req = urllib.request.Request( + f"{_RELEASES_URL}?limit=10", + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + releases = json.loads(resp.read()) + + best = None + best_version = current + + for release in releases: + if release.get("draft"): + continue + if release.get("prerelease"): + continue + + tag = release.get("tag_name", "") + # Skip the rolling 'latest' tag + if tag == "latest": + continue + + ver = _parse_version(tag) + if ver is None: + continue + + if ver > best_version: + best_version = ver + best = release + + if best is None: + return None + + assets = [] + for asset in best.get("assets", []): + assets.append( + { + "name": asset.get("name", ""), + "url": asset.get("browser_download_url", ""), + "size": asset.get("size", 0), + } + ) + + return { + "version": ".".join(str(x) for x in best_version), + "tag": best["tag_name"], + "release_url": best.get("html_url", ""), + "assets": assets, + "body": best.get("body", ""), + } + + +def _should_check(param): + """Determine whether an update check should run now. + + Args: + param: FreeCAD parameter group for update preferences. + + Returns: + True if a check should be performed. + """ + if not param.GetBool("CheckEnabled", True): + return False + + last_check = param.GetString("LastCheckTimestamp", "") + if not last_check: + return True + + interval_days = param.GetInt("CheckIntervalDays", 1) + if interval_days <= 0: + return True + + try: + last_dt = datetime.fromisoformat(last_check) + now = datetime.now(timezone.utc) + elapsed = (now - last_dt).total_seconds() + return elapsed >= interval_days * 86400 + except (ValueError, TypeError): + return True + + +def _run_update_check(): + """Entry point called from the deferred startup timer.""" + param = FreeCAD.ParamGet(_PREF_PATH) + + if not _should_check(param): + return + + try: + from version import VERSION + except ImportError: + FreeCAD.Console.PrintLog( + "Create: update check skipped — version module not available\n" + ) + return + + try: + result = check_for_update(VERSION) + except Exception as e: + FreeCAD.Console.PrintLog(f"Create: update check failed: {e}\n") + return + + # Record that we checked + param.SetString( + "LastCheckTimestamp", + datetime.now(timezone.utc).isoformat(), + ) + + if result is None: + FreeCAD.Console.PrintLog("Create: application is up to date\n") + return + + skipped = param.GetString("SkippedVersion", "") + if result["version"] == skipped: + FreeCAD.Console.PrintLog( + f"Create: update {result['version']} available but skipped by user\n" + ) + return + + FreeCAD.Console.PrintMessage( + f"Kindred Create {result['version']} is available (current: {VERSION})\n" + ) diff --git a/src/Mod/Create/version.py.in b/src/Mod/Create/version.py.in new file mode 100644 index 0000000000..05a09531c1 --- /dev/null +++ b/src/Mod/Create/version.py.in @@ -0,0 +1 @@ +VERSION = "@KINDRED_CREATE_VERSION@"