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 +