diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index f2f4f9c59..8d3e03e81 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,9 +1,21 @@ import { autoUpdater } from "electron-updater"; -import { BrowserWindow, ipcMain } from "electron"; +import { app, BrowserWindow, ipcMain } from "electron"; autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; +const STARTUP_CHECK_DELAY_MS = 5_000; +const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +export type ManualUpdateCheckResult = + | { + ok: true; + currentVersion: string; + latestVersion: string; + available: boolean; + } + | { ok: false; error: string }; + export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void { autoUpdater.on("update-available", (info) => { const win = getMainWindow(); @@ -37,10 +49,37 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi autoUpdater.quitAndInstall(false, true); }); - // Check for updates after a short delay to avoid blocking startup + ipcMain.handle("updater:check", async (): Promise => { + try { + const result = await autoUpdater.checkForUpdates(); + const currentVersion = app.getVersion(); + const latestVersion = result?.updateInfo.version ?? currentVersion; + return { + ok: true, + currentVersion, + latestVersion, + available: latestVersion !== currentVersion, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + // Initial check shortly after startup so we don't block boot. setTimeout(() => { autoUpdater.checkForUpdates().catch((err) => { console.error("Failed to check for updates:", err); }); - }, 5000); + }, STARTUP_CHECK_DELAY_MS); + + // Background poll so long-running sessions still pick up new releases + // without requiring the user to restart the app. + setInterval(() => { + autoUpdater.checkForUpdates().catch((err) => { + console.error("Periodic update check failed:", err); + }); + }, PERIODIC_CHECK_INTERVAL_MS); } diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index 961c58a66..d739b624b 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -53,6 +53,10 @@ interface UpdaterAPI { onUpdateDownloaded: (callback: () => void) => () => void; downloadUpdate: () => Promise; installUpdate: () => Promise; + checkForUpdates: () => Promise< + | { ok: true; currentVersion: string; latestVersion: string; available: boolean } + | { ok: false; error: string } + >; } declare global { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index a87c12cac..5ff3ea196 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -96,6 +96,10 @@ const updaterAPI = { }, downloadUpdate: () => ipcRenderer.invoke("updater:download"), installUpdate: () => ipcRenderer.invoke("updater:install"), + checkForUpdates: (): Promise< + | { ok: true; currentVersion: string; latestVersion: string; available: boolean } + | { ok: false; error: string } + > => ipcRenderer.invoke("updater:check"), }; if (process.contextIsolated) { diff --git a/apps/desktop/src/renderer/src/components/updates-settings-tab.tsx b/apps/desktop/src/renderer/src/components/updates-settings-tab.tsx new file mode 100644 index 000000000..0e70943b7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/updates-settings-tab.tsx @@ -0,0 +1,86 @@ +import { useCallback, useState } from "react"; +import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react"; +import { Button } from "@multica/ui/components/ui/button"; + +type CheckState = + | { status: "idle" } + | { status: "checking" } + | { status: "up-to-date"; currentVersion: string } + | { status: "available"; latestVersion: string } + | { status: "error"; message: string }; + +export function UpdatesSettingsTab() { + const [state, setState] = useState({ status: "idle" }); + + const handleCheck = useCallback(async () => { + setState({ status: "checking" }); + const result = await window.updater.checkForUpdates(); + if (!result.ok) { + setState({ status: "error", message: result.error }); + return; + } + setState( + result.available + ? { status: "available", latestVersion: result.latestVersion } + : { status: "up-to-date", currentVersion: result.currentVersion }, + ); + }, []); + + return ( +
+

Updates

+

+ The desktop app checks for new versions automatically once an hour and + shortly after launch. +

+ +
+
+
+

Check for updates

+

+ Trigger a check now instead of waiting for the next automatic + poll. Available updates appear as a notification in the corner. +

+ {state.status === "up-to-date" && ( +

+ + You're on the latest version (v{state.currentVersion}). +

+ )} + {state.status === "available" && ( +

+ + v{state.latestVersion} is available — see the download prompt + in the corner. +

+ )} + {state.status === "error" && ( +

+ + {state.message} +

+ )} +
+
+ +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index f8aed383c..6f9ad902a 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -19,8 +19,9 @@ import { DaemonRuntimeCard } from "./components/daemon-runtime-card"; import { AgentsPage } from "@multica/views/agents"; import { InboxPage } from "@multica/views/inbox"; import { SettingsPage } from "@multica/views/settings"; -import { Server } from "lucide-react"; +import { Download, Server } from "lucide-react"; import { DaemonSettingsTab } from "./components/daemon-settings-tab"; +import { UpdatesSettingsTab } from "./components/updates-settings-tab"; import { WorkspaceRouteLayout } from "./components/workspace-route-layout"; /** @@ -130,6 +131,12 @@ export const appRoutes: RouteObject[] = [ icon: Server, content: , }, + { + value: "updates", + label: "Updates", + icon: Download, + content: , + }, ]} /> ),