From f62c973748b0b490f88355f27abed8d8e4da1edc Mon Sep 17 00:00:00 2001
From: MaximEdogawa
Date: Sun, 19 Apr 2026 01:39:03 +0200
Subject: [PATCH] feat: implement updater module for release management
- Added an API to fetch the latest release from GitHub, including caching functionality.
- Introduced components for downloading the latest release and indicating updates.
- Implemented platform detection and asset selection for downloads.
- Integrated the updater module into the Dashboard and Landing pages.
---
src/modules/updater/api.ts | 76 +++++++++++++++++++
.../components/DownloadLatestButton.tsx | 44 +++++++++++
.../updater/components/UpdateIndicator.tsx | 42 ++++++++++
src/modules/updater/download.ts | 9 +++
src/modules/updater/index.ts | 4 +
src/modules/updater/platform.ts | 38 ++++++++++
src/modules/updater/semver.ts | 17 +++++
src/pages/DashboardPage.tsx | 3 +
src/pages/LandingPage.tsx | 3 +
9 files changed, 236 insertions(+)
create mode 100644 src/modules/updater/api.ts
create mode 100644 src/modules/updater/components/DownloadLatestButton.tsx
create mode 100644 src/modules/updater/components/UpdateIndicator.tsx
create mode 100644 src/modules/updater/download.ts
create mode 100644 src/modules/updater/index.ts
create mode 100644 src/modules/updater/platform.ts
create mode 100644 src/modules/updater/semver.ts
diff --git a/src/modules/updater/api.ts b/src/modules/updater/api.ts
new file mode 100644
index 0000000..9cfb1e0
--- /dev/null
+++ b/src/modules/updater/api.ts
@@ -0,0 +1,76 @@
+import { isNewer } from "./semver";
+import type { ReleaseAsset } from "./platform";
+
+const RELEASES_API_URL = "https://api.github.com/repos/pengine-ai/pengine/releases/latest";
+export const RELEASES_PAGE_URL = "https://github.com/pengine-ai/pengine/releases/latest";
+
+const CACHE_KEY = "pengine-updater-cache";
+const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+
+export type LatestRelease = {
+ tag: string;
+ htmlUrl: string;
+ assets: ReleaseAsset[];
+ checkedAt: number;
+};
+
+export async function getLatestRelease(options?: {
+ force?: boolean;
+}): Promise {
+ const cached = readCache();
+ if (!options?.force && cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
+ return cached;
+ }
+ try {
+ const res = await fetch(RELEASES_API_URL, {
+ headers: { Accept: "application/vnd.github+json" },
+ });
+ if (!res.ok) return cached;
+ const data = (await res.json()) as {
+ tag_name?: string;
+ html_url?: string;
+ assets?: Array<{ name?: string; browser_download_url?: string }>;
+ };
+ const release: LatestRelease = {
+ tag: String(data.tag_name ?? ""),
+ htmlUrl: String(data.html_url ?? RELEASES_PAGE_URL),
+ assets: (data.assets ?? []).map((a) => ({
+ name: String(a.name ?? ""),
+ browser_download_url: String(a.browser_download_url ?? ""),
+ })),
+ checkedAt: Date.now(),
+ };
+ writeCache(release);
+ return release;
+ } catch {
+ return cached;
+ }
+}
+
+export function hasUpdate(
+ current: string | null | undefined,
+ latest: LatestRelease | null,
+): boolean {
+ if (!current || !latest?.tag) return false;
+ return isNewer(latest.tag, current);
+}
+
+function readCache(): LatestRelease | null {
+ try {
+ const raw = localStorage.getItem(CACHE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as LatestRelease;
+ if (!parsed.tag || typeof parsed.checkedAt !== "number") return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+function writeCache(value: LatestRelease): void {
+ try {
+ localStorage.setItem(CACHE_KEY, JSON.stringify(value));
+ } catch {
+ // storage full or blocked — skip caching, next call will refetch
+ }
+}
diff --git a/src/modules/updater/components/DownloadLatestButton.tsx b/src/modules/updater/components/DownloadLatestButton.tsx
new file mode 100644
index 0000000..7f5502a
--- /dev/null
+++ b/src/modules/updater/components/DownloadLatestButton.tsx
@@ -0,0 +1,44 @@
+import { useEffect, useState } from "react";
+import { getLatestRelease, RELEASES_PAGE_URL, type LatestRelease } from "../api";
+import { openDownload } from "../download";
+import { detectPlatform, pickAssetForPlatform, PLATFORM_LABEL } from "../platform";
+
+type Props = { className?: string };
+
+export function DownloadLatestButton({ className }: Props) {
+ const [release, setRelease] = useState(null);
+ const platform = detectPlatform();
+
+ useEffect(() => {
+ let cancelled = false;
+ void getLatestRelease().then((r) => {
+ if (!cancelled) setRelease(r);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const handleClick = () => {
+ const asset = release ? pickAssetForPlatform(release.assets, platform) : null;
+ const url = asset?.browser_download_url ?? release?.htmlUrl ?? RELEASES_PAGE_URL;
+ void openDownload(url);
+ };
+
+ const label =
+ platform === "unknown" ? "Download latest" : `Download for ${PLATFORM_LABEL[platform]}`;
+
+ return (
+
+ );
+}
diff --git a/src/modules/updater/components/UpdateIndicator.tsx b/src/modules/updater/components/UpdateIndicator.tsx
new file mode 100644
index 0000000..2b031ef
--- /dev/null
+++ b/src/modules/updater/components/UpdateIndicator.tsx
@@ -0,0 +1,42 @@
+import { useEffect, useState } from "react";
+import { getLatestRelease, hasUpdate, RELEASES_PAGE_URL, type LatestRelease } from "../api";
+import { openDownload } from "../download";
+import { detectPlatform, pickAssetForPlatform } from "../platform";
+
+type Props = { currentVersion: string | null };
+
+export function UpdateIndicator({ currentVersion }: Props) {
+ const [release, setRelease] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void getLatestRelease().then((r) => {
+ if (!cancelled) setRelease(r);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (!release || !hasUpdate(currentVersion, release)) return null;
+
+ const asset = pickAssetForPlatform(release.assets, detectPlatform());
+ const downloadUrl = asset?.browser_download_url ?? release.htmlUrl ?? RELEASES_PAGE_URL;
+
+ return (
+
+
+ Update available: {release.tag}
+
+
+ );
+}
diff --git a/src/modules/updater/download.ts b/src/modules/updater/download.ts
new file mode 100644
index 0000000..46ea145
--- /dev/null
+++ b/src/modules/updater/download.ts
@@ -0,0 +1,9 @@
+/** Open a URL the way the current runtime expects: Tauri → default browser, web → new tab. */
+export async function openDownload(url: string): Promise {
+ try {
+ const { openUrl } = await import("@tauri-apps/plugin-opener");
+ await openUrl(url);
+ } catch {
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+}
diff --git a/src/modules/updater/index.ts b/src/modules/updater/index.ts
new file mode 100644
index 0000000..ae17559
--- /dev/null
+++ b/src/modules/updater/index.ts
@@ -0,0 +1,4 @@
+export { UpdateIndicator } from "./components/UpdateIndicator";
+export { DownloadLatestButton } from "./components/DownloadLatestButton";
+export { getLatestRelease, hasUpdate, RELEASES_PAGE_URL } from "./api";
+export type { LatestRelease } from "./api";
diff --git a/src/modules/updater/platform.ts b/src/modules/updater/platform.ts
new file mode 100644
index 0000000..3e8dffe
--- /dev/null
+++ b/src/modules/updater/platform.ts
@@ -0,0 +1,38 @@
+export type Platform = "macos" | "windows" | "linux" | "unknown";
+
+export type ReleaseAsset = { name: string; browser_download_url: string };
+
+export function detectPlatform(): Platform {
+ if (typeof navigator === "undefined") return "unknown";
+ const ua = navigator.userAgent.toLowerCase();
+ if (ua.includes("mac")) return "macos";
+ if (ua.includes("win")) return "windows";
+ if (ua.includes("linux") || ua.includes("x11")) return "linux";
+ return "unknown";
+}
+
+// Preference order matches what `app-release.yml` publishes. First hit wins.
+const EXTENSIONS: Record = {
+ macos: [".dmg"],
+ windows: [".msi", ".exe"],
+ linux: [".AppImage", ".deb"],
+ unknown: [],
+};
+
+export const PLATFORM_LABEL: Record = {
+ macos: "macOS",
+ windows: "Windows",
+ linux: "Linux",
+ unknown: "your platform",
+};
+
+export function pickAssetForPlatform(
+ assets: ReleaseAsset[],
+ platform: Platform,
+): ReleaseAsset | null {
+ for (const ext of EXTENSIONS[platform]) {
+ const match = assets.find((a) => a.name.toLowerCase().endsWith(ext.toLowerCase()));
+ if (match) return match;
+ }
+ return null;
+}
diff --git a/src/modules/updater/semver.ts b/src/modules/updater/semver.ts
new file mode 100644
index 0000000..bace3be
--- /dev/null
+++ b/src/modules/updater/semver.ts
@@ -0,0 +1,17 @@
+/** Minimal semver compare — only MAJOR.MINOR.PATCH, tolerates a leading `v`. */
+export function isNewer(latest: string, current: string): boolean {
+ const a = parse(latest);
+ const b = parse(current);
+ if (!a || !b) return false;
+ for (let i = 0; i < 3; i++) {
+ if (a[i] > b[i]) return true;
+ if (a[i] < b[i]) return false;
+ }
+ return false;
+}
+
+function parse(v: string): [number, number, number] | null {
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
+ if (!m) return null;
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
+}
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx
index e7242e5..ba9ffcc 100644
--- a/src/pages/DashboardPage.tsx
+++ b/src/pages/DashboardPage.tsx
@@ -7,6 +7,7 @@ import { McpToolsPanel } from "../modules/mcp/components/McpToolsPanel";
import { fetchOllamaModels, setPreferredOllamaModel } from "../modules/ollama/api";
import { SkillsPanel } from "../modules/skills";
import { ToolEnginePanel } from "../modules/toolengine/components/ToolEnginePanel";
+import { UpdateIndicator } from "../modules/updater";
import { TopMenu } from "../shared/ui/TopMenu";
type ServiceInfo = {
@@ -223,6 +224,8 @@ export function DashboardPage() {
)}
+
+
{/* ── Terminal (full width) — live runtime log ───────── */}
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
index 2fe36db..b06bccc 100644
--- a/src/pages/LandingPage.tsx
+++ b/src/pages/LandingPage.tsx
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { TerminalPreview } from "../modules/bot/components/TerminalPreview";
+import { DownloadLatestButton } from "../modules/updater";
import { PhoneMockup } from "../shared/ui/PhoneMockup";
import { SpecMockup } from "../shared/ui/SpecMockup";
import { TopMenu } from "../shared/ui/TopMenu";
@@ -130,6 +131,7 @@ export function LandingPage() {
Scan and connect
+
Read the spec
@@ -439,6 +441,7 @@ export function LandingPage() {
Open setup wizard
+