"""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" )