From 250ea2e00db362f9b9b8531551ef70d2007ca5dd Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 14 Dec 2025 17:16:52 -0500 Subject: [PATCH] Improve updater version selection and surface release dates --- pikit-web/assets/releases.js | 40 +++++- pikit-web/data/mock-update-status.json | 3 + pikit-web/index.html | 2 + pikit_api/releases.py | 181 +++++++++++++++++++++---- 4 files changed, 193 insertions(+), 33 deletions(-) diff --git a/pikit-web/assets/releases.js b/pikit-web/assets/releases.js index e755f2f..55a233c 100644 --- a/pikit-web/assets/releases.js +++ b/pikit-web/assets/releases.js @@ -18,6 +18,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo const releaseClose = document.getElementById("releaseClose"); const releaseCurrent = document.getElementById("releaseCurrent"); const releaseLatest = document.getElementById("releaseLatest"); + const releaseCurrentDate = document.getElementById("releaseCurrentDate"); + const releaseLatestDate = document.getElementById("releaseLatestDate"); const releaseStatusMsg = document.getElementById("releaseStatusMsg"); const releaseProgress = document.getElementById("releaseProgress"); const releaseCheckBtn = document.getElementById("releaseCheckBtn"); @@ -47,6 +49,21 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo const logger = createReleaseLogger(logUi); logger.attach(releaseLog); + const fmtDate = (iso) => { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch (e) { + return "—"; + } + }; + function setReleaseChip(state) { if (!releaseFlagTop) return; releaseFlagTop.textContent = "Pi-Kit: n/a"; @@ -69,7 +86,9 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo const msg = shorten(message, 80) || ""; releaseFlagTop.title = msg || "Pi-Kit release status"; if (releaseStatusMsg) { - releaseStatusMsg.textContent = status === "update_available" ? msg || "Update available" : ""; + const isUrlMsg = msg && /^https?:/i.test(msg); + const safeMsg = isUrlMsg ? "Update available" : msg; + releaseStatusMsg.textContent = status === "update_available" ? safeMsg || "Update available" : ""; releaseStatusMsg.classList.remove("error"); } if (releaseLogStatus) { @@ -133,6 +152,9 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo auto_check = false, progress = null, channel = "dev", + current_release_date = null, + latest_release_date = null, + changelog_url = null, } = data || {}; releaseChannel = channel || "dev"; if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev"; @@ -143,14 +165,17 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo lastReleaseLogKey = key; } releaseLastFetched = now; - if (status === "update_available" && message && message.startsWith("http")) { - lastChangelogUrl = message; - } else if (latest_version) { + lastChangelogUrl = changelog_url || null; + if (!lastChangelogUrl && status === "update_available" && message && message.startsWith("http")) { + lastChangelogUrl = message; + } else if (!lastChangelogUrl && latest_version) { lastChangelogUrl = `https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v${latest_version}/CHANGELOG-${latest_version}.txt`; } setReleaseChip(data); if (releaseCurrent) releaseCurrent.textContent = current_version; if (releaseLatest) releaseLatest.textContent = latest_version; + if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date); + if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date); if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check; if (releaseProgress) releaseProgress.textContent = ""; if (status === "in_progress" && progress) { @@ -317,8 +342,11 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo releaseChangelogBtn?.addEventListener("click", async () => { const state = window.__lastReleaseState || {}; - const { latest_version, message } = state; - const url = (message && message.startsWith("http") ? message : null) || lastChangelogUrl; + const { latest_version, message, changelog_url } = state; + const url = + changelog_url || + (message && message.startsWith("http") ? message : null) || + lastChangelogUrl; if (!url) { showToast("No changelog URL available", "error"); return; diff --git a/pikit-web/data/mock-update-status.json b/pikit-web/data/mock-update-status.json index 1648d65..c1609b6 100644 --- a/pikit-web/data/mock-update-status.json +++ b/pikit-web/data/mock-update-status.json @@ -4,6 +4,9 @@ "last_check": "2025-12-10T22:00:00Z", "status": "update_available", "message": "New UI polish and bug fixes.", + "changelog_url": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.1-mock/CHANGELOG-0.1.1-mock.txt", + "latest_release_date": "2025-12-09T18:00:00Z", + "current_release_date": "2025-12-01T17:00:00Z", "auto_check": true, "in_progress": false, "progress": null diff --git a/pikit-web/index.html b/pikit-web/index.html index d927e12..f4015fc 100644 --- a/pikit-web/index.html +++ b/pikit-web/index.html @@ -148,10 +148,12 @@

Current version

n/a

+

Latest available

+

diff --git a/pikit_api/releases.py b/pikit_api/releases.py index af806af..2c9bdea 100644 --- a/pikit_api/releases.py +++ b/pikit_api/releases.py @@ -45,7 +45,11 @@ def load_update_state() -> Dict[str, Any]: UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True) if UPDATE_STATE.exists(): try: - return json.loads(UPDATE_STATE.read_text()) + state = json.loads(UPDATE_STATE.read_text()) + state.setdefault("changelog_url", None) + state.setdefault("latest_release_date", None) + state.setdefault("current_release_date", None) + return state except Exception: pass return { @@ -58,6 +62,9 @@ def load_update_state() -> Dict[str, Any]: "in_progress": False, "progress": None, "channel": os.environ.get("PIKIT_CHANNEL", "dev"), + "changelog_url": None, + "latest_release_date": None, + "current_release_date": None, } @@ -119,32 +126,78 @@ def fetch_manifest(url: str | None = None): raise -def fetch_manifest_for_channel(channel: str): +def _try_fetch(url: Optional[str]): + if not url: + return None + try: + return fetch_manifest(url) + except Exception: + return None + + +def fetch_manifest_for_channel(channel: str, with_meta: bool = False): """ For stable: use normal manifest (latest non-prerelease). For dev: try normal manifest; if it points to stable, fetch latest prerelease manifest via Gitea API. + If a stable build is newer than the latest dev build, prefer the newer stable even on dev channel. """ channel = channel or "dev" base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL + dev_manifest_url = os.environ.get("PIKIT_DEV_MANIFEST_URL") + stable_manifest_url = os.environ.get("PIKIT_STABLE_MANIFEST_URL") or base_manifest_url manifest = None + version_dates: Dict[str, Optional[str]] = {} try: - manifest = fetch_manifest(base_manifest_url) + manifest = fetch_manifest(stable_manifest_url) except Exception: manifest = None - if manifest and channel == "stable": - return manifest - if manifest: - version = manifest.get("version") or manifest.get("latest_version") - if channel == "dev" and version and "dev" in str(version): - return manifest + def _norm_ver(ver): + if ver is None: + return None + s = str(ver).strip() + if s.lower().startswith("v"): + s = s[1:] + return s + + def _newer(a, b): + try: + from distutils.version import LooseVersion + + return LooseVersion(a) > LooseVersion(b) + except Exception: + return a > b + + def _release_version(rel: Dict[str, Any]): + for key in ("tag_name", "name"): + val = rel.get(key) + if val: + v = _norm_ver(val) + if v: + return v + return None + + def _manifest_from_release(rel: Dict[str, Any]): + asset = next((a for a in rel.get("assets", []) if a.get("name") == "manifest.json"), None) + if not asset or not asset.get("browser_download_url"): + return None + mf = fetch_manifest(asset["browser_download_url"]) + if mf: + dt = rel.get("published_at") or rel.get("created_at") + if dt: + mf["_release_date"] = dt + tag = rel.get("tag_name") + if tag: + mf["_release_tag"] = tag + return mf try: parts = base_manifest_url.split("/") if "releases" not in parts: if manifest: - return manifest - return fetch_manifest(base_manifest_url) + return (manifest, {"version_dates": version_dates}) if with_meta else manifest + mf = fetch_manifest(base_manifest_url) + return (mf, {"version_dates": version_dates}) if with_meta else mf idx = parts.index("releases") owner = parts[idx - 2] repo = parts[idx - 1] @@ -157,25 +210,78 @@ def fetch_manifest_for_channel(channel: str): resp = urllib.request.urlopen(req, timeout=10) releases = json.loads(resp.read().decode()) - def pick(predicate): - for r in releases: - if predicate(r): - asset = next((a for a in r.get("assets", []) if a.get("name") == "manifest.json"), None) - if asset and asset.get("browser_download_url"): - return fetch_manifest(asset["browser_download_url"]) - return None + # Map release versions to published dates so we can surface them later + for rel in releases: + v = _release_version(rel) + if v and v not in version_dates: + version_dates[v] = rel.get("published_at") or rel.get("created_at") + + dev_rel = None + stable_rel = None + dev_ver = None + stable_ver = None + for rel in releases: + ver_str = _release_version(rel) + parsed = _norm_ver(ver_str) if ver_str else None + if parsed is None: + continue + if rel.get("prerelease") is True: + if dev_ver is None or _newer(parsed.replace("-", "."), dev_ver): + dev_rel = rel + dev_ver = parsed.replace("-", ".") + elif rel.get("prerelease") is False: + if stable_ver is None or _newer(parsed.replace("-", "."), stable_ver): + stable_rel = rel + stable_ver = parsed.replace("-", ".") + + latest_dev = _manifest_from_release(dev_rel) if dev_rel else None + latest_stable = _manifest_from_release(stable_rel) if stable_rel else None + + # If API didn't give us a dev manifest, try explicitly configured dev URL + if dev_manifest_url and latest_dev is None: + latest_dev = _try_fetch(dev_manifest_url) + if latest_dev and "_release_date" not in latest_dev: + latest_dev["_release_date"] = version_dates.get( + _norm_ver(latest_dev.get("version") or latest_dev.get("latest_version")), None + ) + + # Attach publish date to the base manifest when possible + if manifest: + mver = _norm_ver(manifest.get("version") or manifest.get("latest_version")) + if mver and mver in version_dates and "_release_date" not in manifest: + manifest["_release_date"] = version_dates[mver] if channel == "dev": - m = pick(lambda r: r.get("prerelease") is True) - if m: - return m - m = pick(lambda r: r.get("prerelease") is False) - if m: - return m + # Choose the newest by version comparison across stable/dev/base candidates + candidates = [c for c in (latest_dev, latest_stable, manifest) if c] + best = None + best_ver = None + for c in candidates: + ver = _norm_ver(c.get("version") or c.get("latest_version")) + if not ver: + continue + ver_cmp = ver.replace("-", ".") + if best_ver is None or _newer(ver_cmp, best_ver): + best = c + best_ver = ver_cmp + manifest = best + else: + # stable channel + manifest = latest_stable or manifest except Exception: pass + # As a last resort for dev channel, consider explicitly configured dev manifest even without API data + if channel == "dev" and manifest is None and dev_manifest_url: + manifest = _try_fetch(dev_manifest_url) + + # If still nothing and stable manifest URL is set, try that once more + if manifest is None and stable_manifest_url and stable_manifest_url != base_manifest_url: + manifest = _try_fetch(stable_manifest_url) + if manifest: + if with_meta: + return manifest, {"version_dates": version_dates} return manifest raise RuntimeError("No manifest found for channel") @@ -303,10 +409,25 @@ def check_for_update(): state["progress"] = "Checking for updates…" save_update_state(state) try: - manifest = fetch_manifest_for_channel(state.get("channel") or "dev") + manifest, meta = fetch_manifest_for_channel(state.get("channel") or "dev", with_meta=True) latest = manifest.get("version") or manifest.get("latest_version") state["latest_version"] = latest + state["changelog_url"] = manifest.get("changelog") state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" + version_dates = (meta or {}).get("version_dates") or {} + if manifest.get("_release_date"): + state["latest_release_date"] = manifest.get("_release_date") + elif latest and latest in version_dates: + state["latest_release_date"] = version_dates.get(str(latest)) + else: + state["latest_release_date"] = None + state["current_release_date"] = None + current_ver = state.get("current_version") + if current_ver and current_ver in version_dates: + state["current_release_date"] = version_dates.get(str(current_ver)) + elif current_ver and current_ver == latest and state["latest_release_date"]: + # If current matches latest and we have a date for latest, reuse it + state["current_release_date"] = state["latest_release_date"] channel = state.get("channel") or "dev" if channel == "stable" and latest and "dev" in str(latest): state["status"] = "up_to_date" @@ -314,7 +435,7 @@ def check_for_update(): else: if latest and latest != state.get("current_version"): state["status"] = "update_available" - state["message"] = manifest.get("changelog", "Update available") + state["message"] = manifest.get("notes") or manifest.get("message") or "Update available" else: state["status"] = "up_to_date" state["message"] = "Up to date" @@ -323,6 +444,7 @@ def check_for_update(): state["status"] = "up_to_date" state["message"] = f"Could not reach update server: {e}" state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" + state["latest_release_date"] = None diag_log("error", "Update check failed", {"error": str(e)}) finally: state["in_progress"] = False @@ -371,7 +493,7 @@ def apply_update(): try: channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev") - manifest = fetch_manifest_for_channel(channel) + manifest, meta = fetch_manifest_for_channel(channel, with_meta=True) latest = manifest.get("version") or manifest.get("latest_version") if not latest: raise RuntimeError("Manifest missing version") @@ -428,6 +550,9 @@ def apply_update(): state["current_version"] = str(latest) state["latest_version"] = str(latest) + state["changelog_url"] = manifest.get("changelog") + state["latest_release_date"] = manifest.get("_release_date") or (meta or {}).get("version_dates", {}).get(str(latest)) + state["current_release_date"] = state.get("latest_release_date") state["status"] = "up_to_date" state["message"] = "Update installed" state["progress"] = None @@ -436,11 +561,13 @@ def apply_update(): except urllib.error.HTTPError as e: state["status"] = "error" state["message"] = f"No release available ({e.code})" + state["latest_release_date"] = None diag_log("error", "Update apply HTTP error", {"code": e.code}) except Exception as e: state["status"] = "error" state["message"] = f"Update failed: {e}" state["progress"] = None + state["latest_release_date"] = None save_update_state(state) diag_log("error", "Update apply failed", {"error": str(e)}) backup = choose_rollback_backup()