Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/modules/updater/api.ts
Original file line number Diff line number Diff line change
@@ -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<LatestRelease | null> {
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
}
}
44 changes: 44 additions & 0 deletions src/modules/updater/components/DownloadLatestButton.tsx
Original file line number Diff line number Diff line change
@@ -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<LatestRelease | null>(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 (
<button
type="button"
onClick={handleClick}
className={className ?? "primary-button px-6"}
data-testid="download-latest"
>
{label}
{release?.tag ? (
<span className="ml-2 font-mono text-xs opacity-70">{release.tag}</span>
) : null}
</button>
);
}
42 changes: 42 additions & 0 deletions src/modules/updater/components/UpdateIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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<LatestRelease | null>(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 (
<div
className="mt-2 inline-flex items-center gap-2 rounded-lg border border-yellow-300/30 bg-yellow-300/10 px-3 py-1.5"
data-testid="update-indicator"
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-yellow-300 shadow-[0_0_6px_rgba(252,211,77,0.5)]" />
<span className="font-mono text-[11px] text-yellow-100">Update available: {release.tag}</span>
<button
type="button"
onClick={() => void openDownload(downloadUrl)}
className="rounded-md border border-yellow-300/30 bg-yellow-300/15 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em] text-yellow-100 transition hover:bg-yellow-300/25"
>
Download
</button>
</div>
);
}
9 changes: 9 additions & 0 deletions src/modules/updater/download.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
try {
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(url);
} catch {
window.open(url, "_blank", "noopener,noreferrer");
}
}
4 changes: 4 additions & 0 deletions src/modules/updater/index.ts
Original file line number Diff line number Diff line change
@@ -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";
38 changes: 38 additions & 0 deletions src/modules/updater/platform.ts
Original file line number Diff line number Diff line change
@@ -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<Platform, string[]> = {
macos: [".dmg"],
windows: [".msi", ".exe"],
linux: [".AppImage", ".deb"],
unknown: [],
};

export const PLATFORM_LABEL: Record<Platform, string> = {
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;
}
17 changes: 17 additions & 0 deletions src/modules/updater/semver.ts
Original file line number Diff line number Diff line change
@@ -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])];
}
3 changes: 3 additions & 0 deletions src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -223,6 +224,8 @@ export function DashboardPage() {
</p>
)}

<UpdateIndicator currentVersion={appVersion} />

{/* ── Terminal (full width) — live runtime log ───────── */}
<section className="mt-4 sm:mt-6">
<TerminalPreview />
Expand Down
3 changes: 3 additions & 0 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -130,6 +131,7 @@ export function LandingPage() {
<Link to="/setup" className="primary-button px-6">
Scan and connect
</Link>
<DownloadLatestButton className="secondary-button px-6" />
<a href="#spec" className="secondary-button px-6">
Read the spec
</a>
Expand Down Expand Up @@ -439,6 +441,7 @@ export function LandingPage() {
<Link to="/setup" className="primary-button px-6">
Open setup wizard
</Link>
<DownloadLatestButton className="secondary-button px-6" />
<a
href="https://github.com/pengine-ai/pengine"
className="secondary-button gap-2 px-6"
Expand Down