diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 02f386e51..8e88420a2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -11,8 +11,20 @@ import { WebContentsView, type OpenDialogOptions, } from "electron"; -import { startAutoUpdates, ensureUpdatePrefs } from "./main/auto-updater"; -import { readUpdateSettings, writeUpdateSettings, type UpdateSettings } from "./main/update-settings"; +import { + startAutoUpdates, + ensureUpdatePrefs, + checkForUpdatesNow, + downloadUpdateNow, + quitAndInstallUpdate, + getUpdateStatus, +} from "./main/auto-updater"; +import { + readUpdateSettings, + writeUpdateSettings, + type UpdateSettings, + type UpdateStatus, +} from "./main/update-settings"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile, rm } from "node:fs/promises"; @@ -818,6 +830,19 @@ ipcMain.handle("updateSettings:set", async (_event, settings: UpdateSettings) => await writeUpdateSettings(path.dirname(runFile), settings); }); +ipcMain.handle("updates:getStatus", (): UpdateStatus => getUpdateStatus()); +ipcMain.handle("updates:check", async () => { + const runFile = runFilePath(); + if (!runFile) return; + await checkForUpdatesNow(path.dirname(runFile)); +}); +ipcMain.handle("updates:download", async () => { + await downloadUpdateNow(); +}); +ipcMain.handle("updates:install", () => { + quitAndInstallUpdate(); +}); + ipcMain.handle("notifications:show", (_event, notification: { id: string; title: string; body?: string }) => { if (!notification.id || !notification.title || !ElectronNotification.isSupported()) return; const toast = new ElectronNotification({ diff --git a/frontend/src/main/auto-updater.ts b/frontend/src/main/auto-updater.ts index bab389017..ea6057998 100644 --- a/frontend/src/main/auto-updater.ts +++ b/frontend/src/main/auto-updater.ts @@ -1,8 +1,14 @@ import { autoUpdater } from "electron-updater"; -import { dialog } from "electron"; +import { app, BrowserWindow, dialog } from "electron"; import { existsSync } from "node:fs"; import path from "node:path"; -import { readUpdateSettings, writeUpdateSettings, UPDATE_SETTINGS_FILE_NAME } from "./update-settings"; +import { + readUpdateSettings, + writeUpdateSettings, + UPDATE_SETTINGS_FILE_NAME, + type UpdateChannel, + type UpdateStatus, +} from "./update-settings"; // Default release repo, mirroring backend cli.releaseRepo. Override via env for // fork test builds (AO_RELEASE_REPO=owner/repo). @@ -15,6 +21,50 @@ function repo(): { owner: string; name: string } { return { owner: defOwner, name: defName }; } +// configureFeed points electron-updater at the GitHub Releases feed for the +// chosen channel. Shared by the launch auto-check and the manual Settings flow. +function configureFeed(channel: UpdateChannel): void { + const { owner, name } = repo(); + autoUpdater.setFeedURL({ provider: "github", owner, repo: name }); + autoUpdater.channel = channel; // "latest" | "nightly" + autoUpdater.allowDowngrade = true; // permits a nightly -> stable channel switch +} + +let lastStatus: UpdateStatus = { state: "idle" }; +let eventsWired = false; + +// broadcast pushes the latest update status to every renderer window so the +// Global Settings Updates section can reflect check/download progress live. +function broadcast(status: UpdateStatus): void { + lastStatus = status; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send("updates:status", status); + } +} + +// wireUpdaterEvents registers electron-updater listeners once and forwards each +// to the renderer as an UpdateStatus. Idempotent: safe to call on every entry +// point (launch auto-check and manual check). +function wireUpdaterEvents(): void { + if (eventsWired) return; + eventsWired = true; + autoUpdater.on("checking-for-update", () => broadcast({ state: "checking" })); + autoUpdater.on("update-available", (info) => broadcast({ state: "available", version: info?.version })); + autoUpdater.on("update-not-available", () => broadcast({ state: "not-available" })); + autoUpdater.on("download-progress", (p) => + broadcast({ state: "downloading", percent: Math.max(0, Math.min(100, Math.round(p?.percent ?? 0))) }), + ); + autoUpdater.on("update-downloaded", (info) => broadcast({ state: "downloaded", version: info?.version })); + autoUpdater.on("error", (err) => { + // Never crash on update failure (offline, unsigned macOS, etc.). + broadcast({ state: "error", message: err?.message ?? String(err) }); + }); +} + +export function getUpdateStatus(): UpdateStatus { + return lastStatus; +} + // startAutoUpdates configures electron-updater from the user's ~/.ao settings. // It is a thin shell: all policy (channel, opt-in) comes from update-settings. // Caller guards on app.isPackaged. @@ -22,18 +72,11 @@ export async function startAutoUpdates(stateDir: string): Promise { const settings = await readUpdateSettings(stateDir); if (!settings.enabled) return; - const { owner, name } = repo(); - autoUpdater.setFeedURL({ provider: "github", owner, repo: name }); - autoUpdater.channel = settings.channel; // "latest" | "nightly" - autoUpdater.allowDowngrade = true; // permits a nightly -> stable channel switch + wireUpdaterEvents(); + configureFeed(settings.channel); autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; - autoUpdater.on("error", (err) => { - // Never crash on update failure (offline, unsigned macOS, etc.). - console.error("auto-update error:", err?.message ?? err); - }); - try { await autoUpdater.checkForUpdates(); } catch (err) { @@ -41,6 +84,50 @@ export async function startAutoUpdates(stateDir: string): Promise { } } +// checkForUpdatesNow runs a manual update check regardless of the auto-update +// opt-in, so a user who never enabled auto-updates can still pull the latest +// build from Settings. It does NOT auto-download — the user clicks Update — and +// reports progress via the broadcast status. Updates only work in the packaged, +// signed app; in dev electron-updater has no feed, so surface that plainly. +export async function checkForUpdatesNow(stateDir: string): Promise { + wireUpdaterEvents(); + if (!app.isPackaged) { + broadcast({ state: "unsupported", message: "Updates are only available in the installed app." }); + return; + } + const settings = await readUpdateSettings(stateDir); + configureFeed(settings.channel); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + broadcast({ state: "checking" }); + try { + await autoUpdater.checkForUpdates(); + } catch (err) { + broadcast({ state: "error", message: (err as Error)?.message ?? "Update check failed" }); + } +} + +// downloadUpdateNow starts downloading the update found by checkForUpdatesNow. +export async function downloadUpdateNow(): Promise { + wireUpdaterEvents(); + if (!app.isPackaged) { + broadcast({ state: "unsupported", message: "Updates are only available in the installed app." }); + return; + } + try { + await autoUpdater.downloadUpdate(); + } catch (err) { + broadcast({ state: "error", message: (err as Error)?.message ?? "Download failed" }); + } +} + +// quitAndInstallUpdate installs a downloaded update and relaunches. isSilent +// false keeps the installer UI on Windows; isForceRunAfter relaunches the app. +export function quitAndInstallUpdate(): void { + if (!app.isPackaged) return; + autoUpdater.quitAndInstall(false, true); +} + // ensureUpdatePrefs prompts once (first run, before any settings file exists) // for auto-update opt-in + channel, with a nightly instability disclaimer. export async function ensureUpdatePrefs(stateDir: string): Promise { diff --git a/frontend/src/main/update-settings.ts b/frontend/src/main/update-settings.ts index 30d47a476..03fd539a9 100644 --- a/frontend/src/main/update-settings.ts +++ b/frontend/src/main/update-settings.ts @@ -9,6 +9,25 @@ export interface UpdateSettings { nightlyAck: boolean; } +// Live state of a manual update check/download, streamed to the renderer so the +// Global Settings "Check for updates" / "Update" buttons can reflect progress. +export type UpdateState = + | "idle" + | "checking" + | "available" + | "not-available" + | "downloading" + | "downloaded" + | "error" + | "unsupported"; + +export interface UpdateStatus { + state: UpdateState; + version?: string; + percent?: number; + message?: string; +} + /** File holding the user's auto-update preferences under the ~/.ao state dir. */ export const UPDATE_SETTINGS_FILE_NAME = "update-settings.json"; diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index 3e1bf8beb..2284e6c44 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -3,7 +3,7 @@ import type { BrowserNavState, BrowserRect } from "./main/browser-view-host"; import type { DaemonStatus } from "./shared/daemon-status"; import type { TelemetryBootstrap } from "./shared/telemetry"; import type { MigrationState } from "./main/app-state"; -import type { UpdateSettings } from "./main/update-settings"; +import type { UpdateSettings, UpdateStatus } from "./main/update-settings"; export type BrowserBoundsInput = { viewId: string; @@ -79,6 +79,19 @@ const api = { get: () => ipcRenderer.invoke("updateSettings:get") as Promise, set: (settings: UpdateSettings) => ipcRenderer.invoke("updateSettings:set", settings) as Promise, }, + updates: { + getStatus: () => ipcRenderer.invoke("updates:getStatus") as Promise, + check: () => ipcRenderer.invoke("updates:check") as Promise, + download: () => ipcRenderer.invoke("updates:download") as Promise, + install: () => ipcRenderer.invoke("updates:install") as Promise, + onStatus: (listener: (status: UpdateStatus) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => listener(status); + ipcRenderer.on("updates:status", wrapped); + return () => { + ipcRenderer.off("updates:status", wrapped); + }; + }, + }, }; contextBridge.exposeInMainWorld("ao", api); diff --git a/frontend/src/renderer/components/GlobalSettingsForm.test.tsx b/frontend/src/renderer/components/GlobalSettingsForm.test.tsx index b33a38810..770e35045 100644 --- a/frontend/src/renderer/components/GlobalSettingsForm.test.tsx +++ b/frontend/src/renderer/components/GlobalSettingsForm.test.tsx @@ -1,16 +1,33 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { GlobalSettingsForm } from "./GlobalSettingsForm"; -const { getMock, postMock, getMigration, setMigration, getUpdate, setUpdate } = vi.hoisted(() => ({ +const { + getMock, + postMock, + getMigration, + setMigration, + getUpdate, + setUpdate, + updGetStatus, + updCheck, + updDownload, + updInstall, + updOnStatus, +} = vi.hoisted(() => ({ getMock: vi.fn(), postMock: vi.fn(), getMigration: vi.fn(), setMigration: vi.fn(), getUpdate: vi.fn(), setUpdate: vi.fn(), + updGetStatus: vi.fn(), + updCheck: vi.fn(), + updDownload: vi.fn(), + updInstall: vi.fn(), + updOnStatus: vi.fn(), })); vi.mock("../lib/api-client", () => ({ @@ -22,6 +39,13 @@ vi.mock("../lib/bridge", () => ({ aoBridge: { appState: { getMigration, setMigration }, updateSettings: { get: getUpdate, set: setUpdate }, + updates: { + getStatus: updGetStatus, + check: updCheck, + download: updDownload, + install: updInstall, + onStatus: updOnStatus, + }, }, })); @@ -43,6 +67,11 @@ beforeEach(() => { setMigration.mockResolvedValue(undefined); getUpdate.mockResolvedValue({ enabled: true, channel: "latest", nightlyAck: false }); setUpdate.mockResolvedValue(undefined); + updGetStatus.mockResolvedValue({ state: "idle" }); + updCheck.mockResolvedValue(undefined); + updDownload.mockResolvedValue(undefined); + updInstall.mockResolvedValue(undefined); + updOnStatus.mockReturnValue(() => undefined); }); describe("GlobalSettingsForm", () => { @@ -100,6 +129,41 @@ describe("GlobalSettingsForm", () => { expect(screen.getByRole("button", { name: "Run migration" })).toBeDisabled(); }); + it("Check for updates triggers a manual check", async () => { + renderForm(); + const btn = await screen.findByRole("button", { name: "Check for updates" }); + await userEvent.click(btn); + expect(updCheck).toHaveBeenCalled(); + }); + + it("offers an Update button when an update is available and downloads it", async () => { + let emit: (s: { state: string; version?: string }) => void = () => undefined; + updOnStatus.mockImplementation((cb: (s: unknown) => void) => { + emit = cb as typeof emit; + return () => undefined; + }); + renderForm(); + await screen.findByRole("button", { name: "Check for updates" }); + act(() => emit({ state: "available", version: "1.2.3" })); + const updateBtn = await screen.findByRole("button", { name: "Update to v1.2.3" }); + await userEvent.click(updateBtn); + expect(updDownload).toHaveBeenCalled(); + }); + + it("offers Restart & install once downloaded and installs it", async () => { + let emit: (s: { state: string; version?: string }) => void = () => undefined; + updOnStatus.mockImplementation((cb: (s: unknown) => void) => { + emit = cb as typeof emit; + return () => undefined; + }); + renderForm(); + await screen.findByRole("button", { name: "Check for updates" }); + act(() => emit({ state: "downloaded", version: "1.2.3" })); + const installBtn = await screen.findByRole("button", { name: /Restart & install/ }); + await userEvent.click(installBtn); + expect(updInstall).toHaveBeenCalled(); + }); + it("a failed import surfaces the error and marks failed", async () => { postMock.mockResolvedValue({ data: undefined, error: { message: "disk full" } }); renderForm(); diff --git a/frontend/src/renderer/components/UpdatesSection.tsx b/frontend/src/renderer/components/UpdatesSection.tsx index b4e1ab760..f9817c7d5 100644 --- a/frontend/src/renderer/components/UpdatesSection.tsx +++ b/frontend/src/renderer/components/UpdatesSection.tsx @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; import { aoBridge } from "../lib/bridge"; -import type { UpdateChannel, UpdateSettings } from "../../main/update-settings"; +import type { UpdateChannel, UpdateSettings, UpdateStatus } from "../../main/update-settings"; import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Label } from "./ui/label"; @@ -104,11 +105,85 @@ export function UpdatesSection() { )} {savedAt && !save.isPending && !save.isError && Saved.} + + ); } +// UpdateActions is the on-demand update control: a Check for updates button plus +// an Update button that downloads then installs. It works even when automatic +// updates are off, so users who never opted in can still pull the latest build. +function UpdateActions() { + const [status, setStatus] = useState({ state: "idle" }); + + useEffect(() => { + let live = true; + void aoBridge.updates.getStatus().then((s) => { + if (live) setStatus(s); + }); + const off = aoBridge.updates.onStatus(setStatus); + return () => { + live = false; + off?.(); + }; + }, []); + + const checking = status.state === "checking"; + const downloading = status.state === "downloading"; + const busy = checking || downloading; + + return ( +
+
+ + + {status.state === "available" && ( + + )} + {status.state === "downloaded" && ( + + )} + + +
+
+ ); +} + +function UpdateStatusLine({ status }: { status: UpdateStatus }) { + switch (status.state) { + case "checking": + return Checking for updates…; + case "available": + return ( + + Update available{status.version ? ` (v${status.version})` : ""}. + + ); + case "downloading": + return Downloading… {status.percent ?? 0}%; + case "downloaded": + return Downloaded. Restart to finish updating.; + case "not-available": + return You're on the latest version.; + case "unsupported": + return {status.message ?? "Updates need the installed app."}; + case "error": + return {status.message ?? "Update failed."}; + default: + return null; + } +} + function EnabledSelect({ id, value, onChange }: { id: string; value: boolean; onChange: (value: boolean) => void }) { return (