Add desktop updater UI flow (#2270)

This commit is contained in:
PerishFire 2026-05-19 21:36:51 +08:00 committed by GitHub
parent e94663bfbd
commit ad37fd30cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2587 additions and 227 deletions

View file

@ -29,9 +29,9 @@ import {
} from "@open-design/sidecar";
import { readProcessStamp } from "@open-design/platform";
import { createDesktopRuntime } from "./runtime.js";
import { createDesktopRuntime, type DesktopRuntime } from "./runtime.js";
import { attachDesktopProcessErrorFilter } from "./uncaught-exception.js";
import { createDesktopUpdater, type DesktopUpdater } from "./updater.js";
import { createDesktopUpdater, createDesktopUpdaterScheduler, type DesktopUpdater, type DesktopUpdaterScheduler } from "./updater.js";
// Re-export pure URL-policy helpers so the packaged workspace's
// vitest can pin their behaviour without spinning up a full Electron
@ -273,17 +273,6 @@ function installDesktopMenu(updater: DesktopUpdater): () => void {
return updater.subscribe(rebuild);
}
function scheduleStartupUpdateCheck(updater: DesktopUpdater): void {
if (!updater.shouldAutoCheck()) return;
setTimeout(() => {
void updater.checkForUpdates().then(async (status) => {
if (status.state === "downloaded") await showUpdateResultDialog(updater, status);
}).catch((error: unknown) => {
console.error("desktop update auto-check failed", error);
});
}, 5000).unref();
}
const REGISTER_DESKTOP_AUTH_RETRY_DELAYS_MS = [120, 240, 480, 960, 1500];
const REGISTER_DESKTOP_AUTH_TIMEOUT_MS = 800;
@ -375,7 +364,39 @@ export async function runDesktopMain(
);
}
const desktop = await createDesktopRuntime({
const updater = createDesktopUpdater(
{
currentVersion: options.update?.currentVersion,
downloadRoot: options.update?.downloadRoot,
runtimeBase: runtime.base,
source: runtime.source,
},
{ openPath: (path) => shell.openPath(path) },
);
let desktop: DesktopRuntime | null = null;
let disposeMenu: () => void = () => undefined;
let updateScheduler: DesktopUpdaterScheduler | null = null;
let ipcServer: JsonIpcServerHandle | null = null;
let shuttingDown = false;
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
await options.beforeShutdown?.().catch((error: unknown) => {
console.error("desktop beforeShutdown failed", error);
});
updateScheduler?.stop("shutdown");
disposeMenu();
await ipcServer?.close().catch(() => undefined);
await desktop?.close().catch(() => undefined);
app.quit();
}
function shutdownAndExit(): void {
void shutdown().finally(() => process.exit(0));
}
desktop = await createDesktopRuntime({
desktopAuthSecret,
discoverUrl: options.discoverWebUrl ?? createWebDiscovery(runtime),
discoverDaemonUrl: options.discoverDaemonUrl,
@ -386,36 +407,17 @@ export async function runDesktopMain(
// runtime then mints a FRESH token (new nonce + new exp — replay
// protection still works) and POSTs once more.
registerDesktopAuthWithDaemon: () => registerDesktopAuthWithDaemon(runtime, desktopAuthSecret),
requestQuit: shutdownAndExit,
updater,
});
const updater = createDesktopUpdater(
{
currentVersion: options.update?.currentVersion,
downloadRoot: options.update?.downloadRoot,
runtimeBase: runtime.base,
source: runtime.source,
},
{ openPath: (path) => shell.openPath(path) },
);
const disposeMenu = installDesktopMenu(updater);
scheduleStartupUpdateCheck(updater);
let ipcServer: JsonIpcServerHandle | null = null;
let shuttingDown = false;
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
await options.beforeShutdown?.().catch((error: unknown) => {
console.error("desktop beforeShutdown failed", error);
});
disposeMenu();
await ipcServer?.close().catch(() => undefined);
await desktop.close().catch(() => undefined);
app.quit();
}
function shutdownAndExit(): void {
void shutdown().finally(() => process.exit(0));
}
disposeMenu = installDesktopMenu(updater);
updateScheduler = createDesktopUpdaterScheduler(updater, {
backoffInitialMs: updater.config.checkBackoffInitialMs,
backoffMaxMs: updater.config.checkBackoffMaxMs,
initialDelayMs: updater.config.checkInitialDelayMs,
intervalMs: updater.config.checkIntervalMs,
});
if (updater.shouldAutoCheck()) updateScheduler.start();
attachParentMonitor(shutdown);
@ -429,19 +431,23 @@ export async function runDesktopMain(
socketPath: runtime.ipc,
handler: async (message: unknown) => {
const request = normalizeDesktopSidecarMessage(message);
const activeDesktop = desktop;
if (activeDesktop == null) {
throw new Error("desktop runtime is not initialized");
}
switch (request.type) {
case SIDECAR_MESSAGES.STATUS:
return { ...desktop.status(), update: await updater.status() };
return { ...activeDesktop.status(), update: await updater.status() };
case SIDECAR_MESSAGES.EVAL:
return await desktop.eval(request.input as DesktopEvalInput);
return await activeDesktop.eval(request.input as DesktopEvalInput);
case SIDECAR_MESSAGES.SCREENSHOT:
return await desktop.screenshot(request.input as DesktopScreenshotInput);
return await activeDesktop.screenshot(request.input as DesktopScreenshotInput);
case SIDECAR_MESSAGES.CONSOLE:
return desktop.console();
return activeDesktop.console();
case SIDECAR_MESSAGES.CLICK:
return await desktop.click(request.input as DesktopClickInput);
return await activeDesktop.click(request.input as DesktopClickInput);
case SIDECAR_MESSAGES.EXPORT_PDF:
return await desktop.exportPdf(request.input as DesktopExportPdfInput);
return await activeDesktop.exportPdf(request.input as DesktopExportPdfInput);
case SIDECAR_MESSAGES.UPDATE:
return await updater.handle((request.input as DesktopUpdateInput).action);
case SIDECAR_MESSAGES.SHUTDOWN:
@ -464,7 +470,7 @@ export async function runDesktopMain(
});
app.on("activate", () => {
desktop.show();
desktop?.show();
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {

View file

@ -5,10 +5,14 @@ import type {
OpenDesignHostActionResult,
OpenDesignHostFailure,
OpenDesignHostProjectImportResult,
OpenDesignHostUpdaterActionOptions,
OpenDesignHostUpdaterStatusListener,
OpenDesignHostUpdaterStatusSnapshot,
} from '@open-design/host';
const OPEN_DESIGN_HOST_GLOBAL: typeof import('@open-design/host').OPEN_DESIGN_HOST_GLOBAL = '__od__';
const OPEN_DESIGN_HOST_VERSION: typeof import('@open-design/host').OPEN_DESIGN_HOST_VERSION = 1;
const UPDATER_STATUS_EVENT = 'od:update:status-changed';
type PrintPdfOptions = {
deck?: boolean;
@ -116,6 +120,40 @@ const shell = {
},
};
function invokeUpdater(
action: 'check' | 'download' | 'install' | 'status',
options?: OpenDesignHostUpdaterActionOptions,
): Promise<OpenDesignHostUpdaterStatusSnapshot> {
return ipcRenderer.invoke(`od:update:${action}`, options ?? null);
}
const updater = {
check: (options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostUpdaterStatusSnapshot> =>
invokeUpdater('check', options),
download: (options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostUpdaterStatusSnapshot> =>
invokeUpdater('download', options),
install: (options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostUpdaterStatusSnapshot> =>
invokeUpdater('install', options),
quit: async (options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostActionResult> => {
try {
return await ipcRenderer.invoke('od:update:quit', options ?? null);
} catch (error) {
return actionFailure(reasonFromError(error));
}
},
status: (options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostUpdaterStatusSnapshot> =>
invokeUpdater('status', options),
subscribe: (listener: OpenDesignHostUpdaterStatusListener): (() => void) => {
const handler = (_event: unknown, status: OpenDesignHostUpdaterStatusSnapshot): void => {
listener(status);
};
ipcRenderer.on(UPDATER_STATUS_EVENT, handler);
return () => {
ipcRenderer.removeListener(UPDATER_STATUS_EVENT, handler);
};
},
};
const hostBridge = {
version: OPEN_DESIGN_HOST_VERSION,
client: {
@ -138,6 +176,7 @@ const hostBridge = {
setVisible: (visible: boolean): void =>
ipcRenderer.send('desktop-pet:set-visible', Boolean(visible)),
},
updater,
} satisfies OpenDesignHostBridge;
contextBridge.exposeInMainWorld(OPEN_DESIGN_HOST_GLOBAL, hostBridge);

View file

@ -4,10 +4,19 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BrowserWindow, dialog, ipcMain, screen, shell } from "electron";
import type { DesktopExportPdfInput, DesktopExportPdfResult } from "@open-design/sidecar-proto";
import {
DESKTOP_UPDATE_CHANNELS,
DESKTOP_UPDATE_MODES,
DESKTOP_UPDATE_STATES,
type DesktopExportPdfInput,
type DesktopExportPdfResult,
type DesktopUpdateStatusSnapshot,
} from "@open-design/sidecar-proto";
import type { OpenDesignHostActionResult, OpenDesignHostUpdaterActionOptions } from "@open-design/host";
import { createElectronPdfTarget, exportPdfFromHtml, savePrintReadyDocumentAsPdf } from "./pdf-export.js";
import type { PrintReadyPdfOptions } from "./pdf-export.js";
import type { DesktopUpdater } from "./updater.js";
/**
* Result of validating a candidate path before exposing it to a
@ -208,6 +217,14 @@ const MAX_CONSOLE_ENTRIES = 200;
const DESKTOP_PET_WINDOW_WIDTH = 360;
const DESKTOP_PET_WINDOW_HEIGHT = 300;
const DESKTOP_PET_WINDOW_MARGIN = 24;
const UPDATER_STATUS_EVENT = "od:update:status-changed";
const UPDATER_IPC_CHANNELS = [
"od:update:status",
"od:update:check",
"od:update:download",
"od:update:install",
"od:update:quit",
] as const;
export type DesktopEvalInput = {
expression: string;
@ -300,6 +317,8 @@ export type DesktopRuntimeOptions = {
* skip it (the lazy retry then collapses into a single attempt).
*/
registerDesktopAuthWithDaemon?: () => Promise<boolean>;
requestQuit?: () => void;
updater?: DesktopUpdater;
};
const DESKTOP_IMPORT_TOKEN_HEADER = "X-OD-Desktop-Import-Token";
@ -776,6 +795,36 @@ function parsePrintReadyPdfOptions(value: unknown): PrintReadyPdfOptions {
return deck === true ? { deck: true } : {};
}
function unavailableUpdaterStatus(): DesktopUpdateStatusSnapshot {
return {
arch: process.arch,
capabilities: {
canApplyInPlace: false,
canDownload: false,
canOpenInstaller: false,
requiresManualInstall: false,
},
channel: DESKTOP_UPDATE_CHANNELS.BETA,
currentVersion: "0.0.0",
enabled: false,
error: {
code: "updater-unavailable",
message: "Desktop updater is not available.",
},
mode: DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER,
platform: process.platform,
state: DESKTOP_UPDATE_STATES.UNSUPPORTED,
supported: false,
};
}
function checkOptionsFromHost(options: unknown): { autoDownload?: boolean } | undefined {
const input = options as OpenDesignHostUpdaterActionOptions | null | undefined;
const payload = input?.payload;
if (payload == null || typeof payload.autoDownload !== "boolean") return undefined;
return { autoDownload: payload.autoDownload };
}
export async function createDesktopRuntime(options: DesktopRuntimeOptions): Promise<DesktopRuntime> {
const preloadPath = options.preloadPath ?? join(dirname(fileURLToPath(import.meta.url)), "preload.cjs");
@ -788,6 +837,9 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
ipcMain.removeHandler("dialog:pick-and-import");
ipcMain.removeHandler("shell:open-external");
ipcMain.removeHandler("shell:open-path");
for (const channel of UPDATER_IPC_CHANNELS) {
ipcMain.removeHandler(channel);
}
ipcMain.handle("shell:open-external", async (_event, url: string) => {
if (!isHttpUrl(url)) return false;
try {
@ -931,6 +983,53 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
showWindowButtons(window);
attachDownloadSaveAsDialog(window);
const sendUpdaterStatus = (status = options.updater?.snapshot() ?? unavailableUpdaterStatus()) => {
if (window.isDestroyed()) return;
window.webContents.send(UPDATER_STATUS_EVENT, status);
};
const unsubscribeUpdater = options.updater?.subscribe(() => sendUpdaterStatus()) ?? (() => undefined);
const requireMainWindowSender = (event: Electron.IpcMainInvokeEvent): void => {
if (event.sender !== window.webContents) {
throw new Error("updater IPC is only available to the main Open Design window");
}
};
ipcMain.handle("od:update:status", async (event) => {
requireMainWindowSender(event);
const status = await (options.updater?.status() ?? unavailableUpdaterStatus());
sendUpdaterStatus(status);
return status;
});
ipcMain.handle("od:update:check", async (event, updaterOptions: unknown) => {
requireMainWindowSender(event);
const status = await (options.updater?.checkForUpdates(checkOptionsFromHost(updaterOptions)) ?? unavailableUpdaterStatus());
sendUpdaterStatus(status);
return status;
});
ipcMain.handle("od:update:download", async (event) => {
requireMainWindowSender(event);
const status = await (options.updater?.downloadUpdate() ?? unavailableUpdaterStatus());
sendUpdaterStatus(status);
return status;
});
ipcMain.handle("od:update:install", async (event) => {
requireMainWindowSender(event);
const status = await (options.updater?.installUpdate() ?? unavailableUpdaterStatus());
sendUpdaterStatus(status);
return status;
});
ipcMain.handle("od:update:quit", async (event): Promise<OpenDesignHostActionResult> => {
requireMainWindowSender(event);
const status = await (options.updater?.status() ?? unavailableUpdaterStatus());
if (status.installResult == null) {
return { ok: false, reason: "installer has not been opened" };
}
if (options.requestQuit == null) {
return { ok: false, reason: "desktop quit is not available" };
}
setTimeout(() => options.requestQuit?.(), 0);
return { ok: true };
});
ipcMain.removeAllListeners("desktop-pet:set-visible");
ipcMain.on("desktop-pet:set-visible", (event, visible: unknown) => {
if (petWindow.isDestroyed() || event.sender !== petWindow.webContents) return;
@ -1100,7 +1199,11 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
clearTimeout(timer);
timer = null;
}
unsubscribeUpdater();
ipcMain.removeAllListeners("desktop-pet:set-visible");
for (const channel of UPDATER_IPC_CHANNELS) {
ipcMain.removeHandler(channel);
}
if (!petWindow.isDestroyed()) petWindow.close();
if (!window.isDestroyed()) window.close();
},

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,10 @@ describe("desktop preload host boundary", () => {
expect(runtimeRequires).toEqual(["'electron'"]);
expect(source).toContain("OPEN_DESIGN_HOST_GLOBAL");
expect(source).toContain("satisfies OpenDesignHostBridge");
expect(source).toContain("updater");
expect(source).toContain("invokeUpdater('install'");
expect(source).toContain("od:update:quit");
expect(source).toContain("od:update:status-changed");
expect(source).not.toContain("@open-design/contracts");
expect(source).not.toContain("exposeInMainWorld('electronAPI'");
expect(source).not.toContain('exposeInMainWorld("__odDesktop"');

View file

@ -0,0 +1,61 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = dirname(fileURLToPath(import.meta.url));
const desktopRoot = join(here, "../..");
function source(relativePath: string): string {
return readFileSync(join(desktopRoot, relativePath), "utf8");
}
describe("desktop updater host boundary", () => {
it("routes renderer updater calls through the canonical host IPC surface", () => {
const runtime = source("src/main/runtime.ts");
expect(runtime).toContain("od:update:status");
expect(runtime).toContain("od:update:check");
expect(runtime).toContain("od:update:download");
expect(runtime).toContain("od:update:install");
expect(runtime).toContain("od:update:quit");
expect(runtime).toContain("UPDATER_STATUS_EVENT");
expect(runtime).toContain("event.sender !== window.webContents");
});
it("does not turn automatic startup checks into native desktop dialogs", () => {
const main = source("src/main/index.ts");
const scheduleStart = main.indexOf("updateScheduler = createDesktopUpdaterScheduler");
const nextSection = main.indexOf("attachParentMonitor", scheduleStart);
expect(scheduleStart).toBeGreaterThanOrEqual(0);
expect(nextSection).toBeGreaterThan(scheduleStart);
const scheduleBody = main.slice(scheduleStart, nextSection);
expect(scheduleBody).toContain("updateScheduler.start()");
expect(scheduleBody).not.toContain("showUpdateResultDialog");
});
it("keeps installer launch separate from desktop process shutdown", () => {
const runtime = source("src/main/runtime.ts");
const installStart = runtime.indexOf('ipcMain.handle("od:update:install"');
const installEnd = runtime.indexOf('ipcMain.handle("od:update:quit"');
expect(installStart).toBeGreaterThanOrEqual(0);
expect(installEnd).toBeGreaterThan(installStart);
const installHandler = runtime.slice(installStart, installEnd);
expect(installHandler).toContain("installUpdate()");
expect(installHandler).not.toContain("quit");
expect(installHandler).not.toContain("process.exit");
expect(installHandler).not.toContain("shutdown");
});
it("exposes process quit only as an explicit post-installer-open action", () => {
const runtime = source("src/main/runtime.ts");
const quitStart = runtime.indexOf('ipcMain.handle("od:update:quit"');
const quitEnd = runtime.indexOf('ipcMain.removeAllListeners("desktop-pet:set-visible"');
expect(quitStart).toBeGreaterThanOrEqual(0);
expect(quitEnd).toBeGreaterThan(quitStart);
const quitHandler = runtime.slice(quitStart, quitEnd);
expect(quitHandler).toContain("status.installResult == null");
expect(quitHandler).toContain("requestQuit");
expect(quitHandler).not.toContain("installUpdate()");
});
});

View file

@ -5,7 +5,7 @@ import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
DESKTOP_UPDATE_CHANNELS,
@ -13,10 +13,18 @@ import {
SIDECAR_SOURCES,
} from "@open-design/sidecar-proto";
import { compareVersions, createDesktopUpdater, DESKTOP_UPDATE_ENV, resolveDesktopUpdaterConfig } from "../../src/main/updater.js";
import {
compareVersions,
createDesktopUpdater,
createDesktopUpdaterScheduler,
DESKTOP_UPDATE_ENV,
resolveDesktopUpdaterConfig,
} from "../../src/main/updater.js";
type FixtureServer = {
artifactRequests: () => number;
close: () => Promise<void>;
metadataRequests: () => number;
metadataUrl: string;
};
@ -41,9 +49,12 @@ async function createUpdaterFixture(options: {
const channel = options.channel ?? "stable";
const artifactBody = options.artifactBody ?? "open design updater fixture";
const digest = createHash("sha256").update(artifactBody).digest("hex");
let artifactRequests = 0;
let metadataRequests = 0;
const server = createServer((request, response) => {
const url = request.url ?? "/";
if (url === "/metadata.json") {
metadataRequests += 1;
response.setHeader("content-type", "application/json");
const betaVersion = prereleaseCounterParts(version);
response.end(JSON.stringify({
@ -78,6 +89,7 @@ async function createUpdaterFixture(options: {
return;
}
if (url === "/artifact.dmg") {
artifactRequests += 1;
response.setHeader("content-length", String(Buffer.byteLength(artifactBody)));
response.end(artifactBody);
return;
@ -95,11 +107,13 @@ async function createUpdaterFixture(options: {
});
const address = serverAddress(server);
return {
artifactRequests: () => artifactRequests,
close: async () => {
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
},
metadataRequests: () => metadataRequests,
metadataUrl: `http://${address}/metadata.json`,
};
}
@ -134,7 +148,7 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
}
async function waitForRequestCount(requests: readonly unknown[], count: number): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (requests.length >= count) return;
await new Promise<void>((resolveWait) => setImmediate(resolveWait));
}
@ -183,6 +197,8 @@ describe("desktop updater", () => {
expect(checked.availableVersion).toBe("1.0.1");
expect(checked.checksum?.algorithm).toBe("sha256");
expect(checked.downloadPath).toEqual(expect.any(String));
expect(checked.paths?.manifestPath).toBe(join(root, "metadata.json"));
expect(checked.active?.path).toBe(checked.downloadPath);
expect(relative(await realpath(root), checked.downloadPath ?? "")).not.toMatch(/^\.\./);
expect(await readFile(checked.downloadPath ?? "", "utf8")).toBe("open design updater fixture");
@ -200,6 +216,59 @@ describe("desktop updater", () => {
}
});
it("reuses an already verified matching download during auto-check", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const first = await updater.checkForUpdates();
expect(first.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(first.downloadPath).toEqual(expect.any(String));
expect(fixture.artifactRequests()).toBe(1);
const second = await updater.checkForUpdates();
expect(second.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(second.downloadPath).toBe(first.downloadPath);
expect(second.availableVersion).toBe(first.availableVersion);
expect(fixture.artifactRequests()).toBe(1);
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("reports old flat updater stores as protocol errors without repairing them", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
try {
await writeFile(join(root, ".open-design-updater-root.json"), JSON.stringify({
owner: "open-design-updater",
version: 1,
}));
await writeFile(join(root, "state.json"), "{}");
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.status();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(checked.error?.code).toBe("update-store-invalid-shape");
expect(existsSync(join(root, "state.json"))).toBe(true);
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("reports not-available when metadata is not newer than the current app", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture({ version: "1.0.0" });
@ -244,6 +313,53 @@ describe("desktop updater", () => {
}
});
it("rejects beta metadata when the configured updater channel is stable", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture({ channel: "beta", version: "1.0.1-beta.2" });
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(checked.channel).toBe(DESKTOP_UPDATE_CHANNELS.STABLE);
expect(checked.error?.code).toBe("metadata-channel-mismatch");
expect(checked.downloadPath).toBeUndefined();
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("rejects stable metadata when the configured updater channel is beta", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture({ channel: "stable", version: "1.0.2" });
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: {
...updaterEnv(fixture.metadataUrl),
[DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.1-beta.1",
},
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(checked.channel).toBe(DESKTOP_UPDATE_CHANNELS.BETA);
expect(checked.error?.code).toBe("metadata-channel-mismatch");
expect(checked.downloadPath).toBeUndefined();
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("treats a larger counted beta nightly prerelease as an update", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture({ channel: "beta", version: "1.0.1-beta-nightly.2" });
@ -348,6 +464,160 @@ describe("desktop updater", () => {
}
});
it("starts and stops scheduled polling idempotently", async () => {
const root = makeRoot();
const fetchImpl = vi.fn(async () => metadataResponse("1.0.1"));
try {
const updater = createDesktopUpdater(
{
arch: "arm64",
downloadRoot: root,
env: {
...updaterEnv("https://example.invalid/metadata.json"),
[DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]: "0",
},
source: SIDECAR_SOURCES.TOOLS_PACK,
},
{ fetch: fetchImpl },
);
await updater.checkForUpdates({ autoDownload: false });
fetchImpl.mockClear();
vi.useFakeTimers();
const scheduler = createDesktopUpdaterScheduler(updater, {
backoffInitialMs: 100,
backoffMaxMs: 1000,
initialDelayMs: 10,
intervalMs: 100,
});
scheduler.start();
scheduler.start();
expect(scheduler.isRunning()).toBe(true);
await vi.advanceTimersByTimeAsync(10);
expect(fetchImpl).toHaveBeenCalledTimes(1);
scheduler.stop("test");
scheduler.stop("test");
await vi.advanceTimersByTimeAsync(1000);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(scheduler.isRunning()).toBe(false);
} finally {
vi.useRealTimers();
rmSync(root, { force: true, recursive: true });
}
});
it("does not re-enter polling while a scheduled check is still running", async () => {
const root = makeRoot();
const requests: Array<{ resolve: (response: Response) => void }> = [];
let blockScheduledFetch = false;
const fetchImpl: typeof globalThis.fetch = async () => {
if (!blockScheduledFetch) return metadataResponse("1.0.1");
const request = deferred<Response>();
requests.push(request);
return await request.promise;
};
try {
const updater = createDesktopUpdater(
{
arch: "arm64",
downloadRoot: root,
env: {
...updaterEnv("https://example.invalid/metadata.json"),
[DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]: "0",
},
source: SIDECAR_SOURCES.TOOLS_PACK,
},
{ fetch: fetchImpl },
);
await updater.checkForUpdates({ autoDownload: false });
blockScheduledFetch = true;
vi.useFakeTimers();
const scheduler = createDesktopUpdaterScheduler(updater, {
backoffInitialMs: 100,
backoffMaxMs: 1000,
initialDelayMs: 10,
intervalMs: 100,
});
scheduler.start();
await vi.advanceTimersByTimeAsync(10);
expect(requests).toHaveLength(1);
await vi.advanceTimersByTimeAsync(500);
expect(requests).toHaveLength(1);
requests[0]?.resolve(metadataResponse("1.0.1"));
await Promise.resolve();
await vi.advanceTimersByTimeAsync(0);
scheduler.stop("test");
} finally {
vi.useRealTimers();
rmSync(root, { force: true, recursive: true });
}
});
it("stops scheduled polling after the installer has been opened", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const scheduler = createDesktopUpdaterScheduler(updater, {
backoffInitialMs: 100,
backoffMaxMs: 1000,
initialDelayMs: 10,
intervalMs: 100,
});
await updater.checkForUpdates();
scheduler.start();
expect(scheduler.isRunning()).toBe(true);
await updater.installUpdate();
expect(scheduler.isRunning()).toBe(false);
const requestsBeforeFrozenCheck = fixture.artifactRequests();
await updater.checkForUpdates();
expect(fixture.artifactRequests()).toBe(requestsBeforeFrozenCheck);
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("restores installer-open freeze before polling on cold start", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const downloaded = await updater.checkForUpdates();
const installed = await updater.installUpdate();
expect(installed.installResult?.path).toBe(downloaded.downloadPath);
const metadataRequestsBeforeRestart = fixture.metadataRequests();
const restarted = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await restarted.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(checked.installResult?.path).toBe(downloaded.downloadPath);
expect(fixture.metadataRequests()).toBe(metadataRequestsBeforeRestart);
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("defaults counted beta nightly builds to the beta update channel", () => {
const root = makeRoot();
try {
@ -451,11 +721,11 @@ describe("desktop updater", () => {
source: SIDECAR_SOURCES.TOOLS_PACK,
});
await updater.status();
symlinkSync(outside, join(root, "artifacts"), "dir");
symlinkSync(outside, join(root, "staging"), "dir");
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(checked.error?.code).toBe("download-failed");
expect(checked.error?.code).toBe("update-store-invalid-shape");
expect(existsSync(outsideMarker)).toBe(true);
} finally {
await fixture.close();

View file

@ -15,6 +15,7 @@
import type { ReactNode } from 'react';
import { EntryHelpMenu } from './EntryHelpMenu';
import { Icon } from './Icon';
import { UpdaterPopup } from './UpdaterPopup';
import { useT } from '../i18n';
export type EntryView =
@ -82,6 +83,7 @@ export function EntryNavRail({ view, onViewChange, onNewProject }: Props) {
draggable={false}
/>
</button>
<UpdaterPopup />
<NavButton
ariaLabel={t('entry.navNewProject')}
tooltip={t('entry.navNewProject')}

View file

@ -0,0 +1,216 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { Icon } from './Icon';
import {
deriveUpdaterModel,
openUpdaterInstaller,
quitAfterUpdaterInstallerOpen,
readUpdaterStatus,
subscribeToUpdaterStatus,
type UpdaterModel,
} from '../lib/updater';
import type { OpenDesignHostUpdaterStatusSnapshot } from '@open-design/host';
type InstallState = 'idle' | 'opening' | 'opened';
type QuitState = 'idle' | 'quitting';
function versionText(model: UpdaterModel): string {
const version = model.availableVersion;
return version == null ? 'A new version is ready.' : `Open Design ${version} is ready.`;
}
function navLabel(model: UpdaterModel): string {
if (model.errorMessage != null) return 'Update failed';
if (model.installerOpened) return 'Installer opened';
if (model.downloadProgress != null || model.busy) {
const percent = model.downloadProgress?.percent;
return percent == null ? 'Downloading update' : `Downloading update ${percent}%`;
}
if (model.hasDownloadedInstaller) return 'Update ready';
return 'Update available';
}
export function UpdaterPopup() {
const [model, setModel] = useState<UpdaterModel>(() => deriveUpdaterModel(null));
const [dismissedPromptKey, setDismissedPromptKey] = useState<string | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [installState, setInstallState] = useState<InstallState>('idle');
const [quitState, setQuitState] = useState<QuitState>('idle');
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const applyStatus = (status: OpenDesignHostUpdaterStatusSnapshot) => {
if (!mounted) return;
setModel(deriveUpdaterModel(status, { hostAvailable: true }));
};
const unsubscribe = subscribeToUpdaterStatus(applyStatus);
void readUpdaterStatus({ payload: { source: 'updater-popup:mount' } }).then((result) => {
if (!mounted) return;
if (result.ok) {
setModel(result.model);
} else {
setModel(deriveUpdaterModel(null, { hostAvailable: false }));
}
});
return () => {
mounted = false;
unsubscribe();
};
}, []);
const isPanelOpen = useMemo(() => {
if (actionError != null) return true;
if (panelOpen) return true;
if (!model.shouldPrompt || model.promptKey == null) return false;
return model.promptKey !== dismissedPromptKey;
}, [actionError, dismissedPromptKey, model.promptKey, model.shouldPrompt, panelOpen]);
if (model.environment !== 'desktop' || !model.shouldShowControl) return null;
const close = () => {
if (model.promptKey != null) setDismissedPromptKey(model.promptKey);
setPanelOpen(false);
setInstallState('idle');
setQuitState('idle');
setActionError(null);
};
const openInstaller = async () => {
setInstallState('opening');
setActionError(null);
const result = await openUpdaterInstaller({ payload: { source: 'updater-popup' } });
if (!result.ok) {
setActionError(result.reason);
setInstallState('idle');
return;
}
setModel(result.model);
if (result.model.errorMessage != null) {
setActionError(result.model.errorMessage);
setInstallState('idle');
return;
}
setInstallState('opened');
setPanelOpen(true);
};
const quitOpenDesign = async () => {
setQuitState('quitting');
setActionError(null);
const result = await quitAfterUpdaterInstallerOpen({ payload: { source: 'updater-popup' } });
if (!result.ok) {
setActionError(result.reason);
setQuitState('idle');
}
};
const opened = installState === 'opened' || model.installerOpened;
const statusError = model.errorMessage;
const failed = actionError != null || statusError != null;
const title = failed ? (opened ? 'Could not quit' : 'Update failed') : opened ? 'Installer opened' : 'Update ready';
const body = failed
? opened
? 'Open Design could not quit.'
: statusError ?? 'The installer could not be opened.'
: opened
? 'The installer is open. Quit Open Design before replacing the app.'
: versionText(model);
const progress = model.downloadProgress;
const progressStyle = {
'--updater-progress': `${progress?.percent ?? 100}%`,
} as CSSProperties;
const controlDisabled = model.busy && !model.hasDownloadedInstaller && !model.installerOpened;
const controlLabel = navLabel(model);
const canOpenInstaller = model.canOpenInstaller && model.hasDownloadedInstaller;
return (
<div className="entry-updater-menu">
<button
aria-disabled={controlDisabled ? 'true' : undefined}
aria-expanded={isPanelOpen}
aria-label={controlLabel}
className={`entry-nav-rail__btn entry-updater-menu__button${isPanelOpen ? ' is-active' : ''}${controlDisabled ? ' is-disabled' : ''}`}
data-testid="entry-nav-updater"
data-tooltip={controlLabel}
type="button"
onClick={() => {
if (controlDisabled) return;
setPanelOpen((open) => !open);
}}
>
<Icon name={opened ? 'check' : 'download'} size={18} />
{progress != null ? (
<span
aria-label={controlLabel}
aria-valuemax={100}
aria-valuemin={0}
{...(progress.percent == null ? {} : { 'aria-valuenow': progress.percent })}
className="entry-updater-menu__progress"
data-testid="entry-nav-updater-progress"
role="progressbar"
style={progressStyle}
/>
) : null}
</button>
{isPanelOpen ? (
<section
aria-labelledby="updater-popup-title"
className="updater-popup"
data-testid="updater-popup"
role="dialog"
>
<div className="updater-popup__icon">
<Icon name={opened ? 'check' : 'download'} size={20} />
</div>
<div className="updater-popup__body">
<h2 id="updater-popup-title">{title}</h2>
<p>{body}</p>
{actionError != null && actionError !== body ? <p className="updater-popup__error">{actionError}</p> : null}
</div>
<div className="updater-popup__actions">
{opened ? (
<>
<button className="updater-popup__button" type="button" onClick={close}>
Done
</button>
<button
className="updater-popup__button updater-popup__button--primary"
data-testid="updater-quit-button"
disabled={!model.canQuitAfterInstallerOpen || quitState === 'quitting'}
type="button"
onClick={() => {
void quitOpenDesign();
}}
>
{quitState === 'quitting' ? 'Quitting...' : 'Quit Open Design'}
</button>
</>
) : failed ? (
<button className="updater-popup__button" type="button" onClick={close}>
Done
</button>
) : (
<>
<button className="updater-popup__button" type="button" onClick={close}>
Later
</button>
<button
className="updater-popup__button updater-popup__button--primary"
data-testid="updater-install-button"
disabled={installState === 'opening' || !canOpenInstaller}
type="button"
onClick={() => {
void openInstaller();
}}
>
{installState === 'opening' ? 'Opening...' : 'Open installer'}
</button>
</>
)}
</div>
</section>
) : null}
</div>
);
}

View file

@ -2553,6 +2553,103 @@ a.avatar-item:visited {
}
.modal .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.updater-popup {
position: absolute;
top: 0;
left: calc(100% + 12px);
z-index: 80;
width: min(360px, calc(100vw - var(--entry-rail-width, 56px) - 24px));
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 14px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elevated);
color: var(--text);
box-shadow: var(--shadow-lg);
-webkit-app-region: no-drag;
}
[dir='rtl'] .entry-updater-menu .updater-popup {
left: auto;
right: calc(100% + 12px);
}
.updater-popup__icon {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 8px;
background: color-mix(in oklab, var(--accent) 12%, var(--bg-subtle));
color: var(--accent);
}
.updater-popup__body {
min-width: 0;
}
.updater-popup__body h2 {
margin: 0;
font-size: 16px;
line-height: 1.25;
font-weight: 650;
}
.updater-popup__body p {
margin: 6px 0 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1.45;
}
.updater-popup__error {
color: var(--red) !important;
}
.updater-popup__actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
min-height: 34px;
}
.updater-popup__button {
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--border-strong);
border-radius: 7px;
background: var(--bg-panel);
color: var(--text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.updater-popup__button:hover {
border-color: var(--text-muted);
}
.updater-popup__button:disabled {
cursor: default;
opacity: 0.55;
}
.updater-popup__button--primary {
border-color: var(--accent);
background: var(--accent);
color: #ffffff;
}
@media (max-width: 560px) {
.updater-popup {
width: calc(100vw - var(--entry-rail-width, 56px) - 18px);
}
}
/* Compact rename modal */
.modal-rename {
width: 420px;

178
apps/web/src/lib/updater.ts Normal file
View file

@ -0,0 +1,178 @@
import {
OPEN_DESIGN_HOST_UPDATER_STATES,
checkHostUpdater,
downloadHostUpdater,
getHostUpdaterStatus,
installHostUpdater,
isOpenDesignHostAvailable,
quitHostAfterUpdaterInstallerOpen,
subscribeHostUpdater,
type OpenDesignHostActionResult,
type OpenDesignHostFailure,
type OpenDesignHostUpdaterActionOptions,
type OpenDesignHostUpdaterResult,
type OpenDesignHostUpdaterStatusListener,
type OpenDesignHostUpdaterStatusSnapshot,
} from '@open-design/host';
export type UpdaterEnvironment = 'desktop' | 'web';
export type UpdaterDownloadProgress = {
percent: number | null;
receivedBytes: number;
totalBytes: number | null;
};
export type UpdaterActionResult =
| { ok: true; model: UpdaterModel; status: OpenDesignHostUpdaterStatusSnapshot }
| OpenDesignHostFailure;
export type UpdaterModel = {
availableVersion: string | null;
busy: boolean;
canCheck: boolean;
canDownload: boolean;
canOpenInstaller: boolean;
canQuitAfterInstallerOpen: boolean;
currentVersion: string | null;
downloadProgress: UpdaterDownloadProgress | null;
enabled: boolean;
environment: UpdaterEnvironment;
errorMessage: string | null;
hasDownloadedInstaller: boolean;
installerOpened: boolean;
promptKey: string | null;
shouldShowControl: boolean;
shouldPrompt: boolean;
status: OpenDesignHostUpdaterStatusSnapshot | null;
supported: boolean;
};
function modelFromHostResult(result: OpenDesignHostUpdaterResult): UpdaterActionResult {
if (!result.ok) return result;
return {
ok: true,
model: deriveUpdaterModel(result.status, { hostAvailable: true }),
status: result.status,
};
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function downloadProgressFromStatus(
status: OpenDesignHostUpdaterStatusSnapshot | null,
): UpdaterDownloadProgress | null {
if (status == null) return null;
const sourceProgress = status.incoming?.progress ?? status.progress;
if (sourceProgress == null && status.state !== OPEN_DESIGN_HOST_UPDATER_STATES.DOWNLOADING) return null;
const receivedBytes = Math.max(0, sourceProgress?.receivedBytes ?? 0);
const totalBytes =
typeof sourceProgress?.totalBytes === 'number' && sourceProgress.totalBytes > 0
? sourceProgress.totalBytes
: null;
const percent = totalBytes == null ? null : clampPercent((receivedBytes / totalBytes) * 100);
return {
percent,
receivedBytes,
totalBytes,
};
}
export function deriveUpdaterModel(
status: OpenDesignHostUpdaterStatusSnapshot | null,
options: { hostAvailable?: boolean } = {},
): UpdaterModel {
const hostAvailable = options.hostAvailable ?? isOpenDesignHostAvailable();
const environment: UpdaterEnvironment = hostAvailable ? 'desktop' : 'web';
const state = status?.state;
const busy =
state === OPEN_DESIGN_HOST_UPDATER_STATES.CHECKING ||
state === OPEN_DESIGN_HOST_UPDATER_STATES.DOWNLOADING ||
state === OPEN_DESIGN_HOST_UPDATER_STATES.INSTALLING;
const canOpenInstaller = Boolean(
hostAvailable &&
status?.enabled &&
status.supported &&
status.capabilities.canOpenInstaller,
);
const hasDownloadedInstaller = Boolean(
state === OPEN_DESIGN_HOST_UPDATER_STATES.DOWNLOADED &&
status?.downloadPath,
);
const installerOpened = status?.installResult != null;
const availableVersion = status?.availableVersion ?? null;
const currentVersion = status?.currentVersion ?? null;
const downloadProgress = downloadProgressFromStatus(status);
const promptKey =
status == null || availableVersion == null
? null
: [
status.channel,
currentVersion ?? 'unknown-current',
availableVersion,
status.downloadPath ?? status.artifactUrl ?? status.artifact?.url ?? 'unknown-artifact',
].join(':');
const canQuitAfterInstallerOpen = hostAvailable && installerOpened;
const hasVisibleUpdaterState = Boolean(
hostAvailable &&
status?.enabled &&
status.supported &&
(busy ||
downloadProgress != null ||
availableVersion != null ||
hasDownloadedInstaller ||
installerOpened ||
status.error != null),
);
return {
availableVersion,
busy,
canCheck: hostAvailable && Boolean(status?.enabled) && !busy,
canDownload: hostAvailable && Boolean(status?.enabled && status.capabilities.canDownload) && !busy,
canOpenInstaller,
canQuitAfterInstallerOpen,
currentVersion,
downloadProgress,
enabled: Boolean(status?.enabled),
environment,
errorMessage: status?.error?.message ?? null,
hasDownloadedInstaller,
installerOpened,
promptKey,
shouldShowControl: hasVisibleUpdaterState,
shouldPrompt: canOpenInstaller && hasDownloadedInstaller && !installerOpened,
status,
supported: Boolean(status?.supported),
};
}
export async function readUpdaterStatus(options?: OpenDesignHostUpdaterActionOptions): Promise<UpdaterActionResult> {
return modelFromHostResult(await getHostUpdaterStatus(options));
}
export async function checkForUpdaterUpdate(options?: OpenDesignHostUpdaterActionOptions): Promise<UpdaterActionResult> {
return modelFromHostResult(await checkHostUpdater(options));
}
export async function downloadUpdaterUpdate(options?: OpenDesignHostUpdaterActionOptions): Promise<UpdaterActionResult> {
return modelFromHostResult(await downloadHostUpdater(options));
}
export async function openUpdaterInstaller(options?: OpenDesignHostUpdaterActionOptions): Promise<UpdaterActionResult> {
return modelFromHostResult(await installHostUpdater(options));
}
export async function quitAfterUpdaterInstallerOpen(
options?: OpenDesignHostUpdaterActionOptions,
): Promise<OpenDesignHostActionResult> {
return await quitHostAfterUpdaterInstallerOpen(options);
}
export function subscribeToUpdaterStatus(listener: OpenDesignHostUpdaterStatusListener): () => void {
return subscribeHostUpdater(listener);
}

View file

@ -171,6 +171,43 @@
flex-direction: row-reverse;
text-align: right;
}
.entry-updater-menu {
position: relative;
display: inline-flex;
}
.entry-updater-menu__button {
position: relative;
overflow: hidden;
}
.entry-updater-menu__button.is-disabled {
cursor: default;
color: var(--text-faint);
}
.entry-updater-menu__button.is-disabled:hover {
background: transparent;
color: var(--text-faint);
}
.entry-updater-menu__progress {
position: absolute;
left: 6px;
right: 6px;
bottom: 4px;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 18%, transparent);
pointer-events: none;
}
.entry-updater-menu__progress::after {
content: '';
display: block;
width: var(--updater-progress, 100%);
height: 100%;
border-radius: inherit;
background: var(--accent);
transition: width 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
.entry-nav-rail__logo {
appearance: none;
width: 36px;

View file

@ -0,0 +1,179 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { OpenDesignHostUpdaterStatusListener, OpenDesignHostUpdaterStatusSnapshot } from '@open-design/host';
import { installMockOpenDesignHost } from '@open-design/host/testing';
import { UpdaterPopup } from '../../src/components/UpdaterPopup';
function idleStatus(): OpenDesignHostUpdaterStatusSnapshot {
return {
arch: 'arm64',
capabilities: {
canApplyInPlace: false,
canDownload: true,
canOpenInstaller: true,
requiresManualInstall: true,
},
channel: 'beta',
currentVersion: '1.2.3-beta.3',
enabled: true,
mode: 'package-launcher',
platform: 'darwin',
state: 'idle',
supported: true,
};
}
function downloadedStatus(overrides: Partial<OpenDesignHostUpdaterStatusSnapshot> = {}): OpenDesignHostUpdaterStatusSnapshot {
return {
...idleStatus(),
availableVersion: '1.2.3-beta.4',
downloadPath: '/tmp/open-design-updater/Open Design Beta.dmg',
state: 'downloaded',
...overrides,
};
}
describe('UpdaterPopup', () => {
let restoreHost: (() => void) | null = null;
afterEach(() => {
cleanup();
restoreHost?.();
restoreHost = null;
});
it('waits for host status and opens the installer from the popup', async () => {
let status = downloadedStatus();
const install = vi.fn(async () => {
status = downloadedStatus({
installResult: {
dryRun: true,
openedAt: '2026-05-19T00:00:00.000Z',
path: status.downloadPath ?? '/tmp/open-design-updater/Open Design Beta.dmg',
},
});
return status;
});
const quit = vi.fn(async () => ({ ok: true as const }));
restoreHost = installMockOpenDesignHost({
host: {
updater: {
install,
quit,
status: vi.fn(async () => status),
},
},
});
render(<UpdaterPopup />);
expect(await screen.findByRole('dialog', { name: 'Update ready' })).toBeTruthy();
fireEvent.click(screen.getByTestId('updater-install-button'));
expect(await screen.findByRole('dialog', { name: 'Installer opened' })).toBeTruthy();
fireEvent.click(screen.getByTestId('updater-quit-button'));
expect(install).toHaveBeenCalledWith({ payload: { source: 'updater-popup' } });
expect(quit).toHaveBeenCalledWith({ payload: { source: 'updater-popup' } });
});
it('reacts to updater subscription events without polling-only behavior', async () => {
const listeners = new Set<OpenDesignHostUpdaterStatusListener>();
restoreHost = installMockOpenDesignHost({
host: {
updater: {
status: vi.fn(async () => idleStatus()),
subscribe: vi.fn((listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
}),
},
},
});
render(<UpdaterPopup />);
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByTestId('updater-popup')).toBeNull();
act(() => {
for (const listener of listeners) listener(downloadedStatus());
});
expect(await screen.findByRole('dialog', { name: 'Update ready' })).toBeTruthy();
expect(screen.getByTestId('entry-nav-updater')).toBeTruthy();
});
it('shows disabled left-rail progress while an update is downloading', async () => {
restoreHost = installMockOpenDesignHost({
host: {
updater: {
status: vi.fn(async () => downloadedStatus({
progress: {
receivedBytes: 50,
totalBytes: 100,
},
state: 'downloading',
})),
},
},
});
render(<UpdaterPopup />);
const trigger = await screen.findByTestId('entry-nav-updater');
expect(trigger.getAttribute('aria-disabled')).toBe('true');
expect(screen.queryByTestId('updater-popup')).toBeNull();
expect(screen.getByRole('progressbar', { name: 'Downloading update 50%' }).getAttribute('aria-valuenow')).toBe('50');
});
it('keeps the popup open when opening the installer returns an updater error state', async () => {
restoreHost = installMockOpenDesignHost({
host: {
updater: {
install: vi.fn(async () => downloadedStatus({
error: {
code: 'open-installer-failed',
message: 'fixture open failed',
},
state: 'error',
})),
status: vi.fn(async () => downloadedStatus()),
},
},
});
render(<UpdaterPopup />);
expect(await screen.findByRole('dialog', { name: 'Update ready' })).toBeTruthy();
fireEvent.click(screen.getByTestId('updater-install-button'));
expect(await screen.findByRole('dialog', { name: 'Update failed' })).toBeTruthy();
expect(screen.getByText('fixture open failed')).toBeTruthy();
});
it('renders status errors as failed updates instead of a disabled ready action', async () => {
restoreHost = installMockOpenDesignHost({
host: {
updater: {
status: vi.fn(async () => downloadedStatus({
downloadPath: undefined,
error: {
code: 'update-store-invalid-shape',
message: 'update store contains unexpected root entries',
},
state: 'error',
})),
},
},
});
render(<UpdaterPopup />);
const trigger = await screen.findByTestId('entry-nav-updater');
fireEvent.click(trigger);
expect(await screen.findByRole('dialog', { name: 'Update failed' })).toBeTruthy();
expect(screen.getByText('update store contains unexpected root entries')).toBeTruthy();
expect(screen.queryByTestId('updater-install-button')).toBeNull();
});
});

View file

@ -0,0 +1,175 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { OpenDesignHostUpdaterStatusSnapshot } from '@open-design/host';
import { installMockOpenDesignHost } from '@open-design/host/testing';
import {
deriveUpdaterModel,
openUpdaterInstaller,
quitAfterUpdaterInstallerOpen,
readUpdaterStatus,
} from '../../src/lib/updater';
function downloadedStatus(overrides: Partial<OpenDesignHostUpdaterStatusSnapshot> = {}): OpenDesignHostUpdaterStatusSnapshot {
return {
arch: 'arm64',
artifact: {
name: 'Open Design Beta.dmg',
platformKey: 'macAppleSilicon',
type: 'dmg',
url: 'https://fixture.test/Open Design Beta.dmg',
},
availableVersion: '1.2.3-beta.4',
capabilities: {
canApplyInPlace: false,
canDownload: true,
canOpenInstaller: true,
requiresManualInstall: true,
},
channel: 'beta',
currentVersion: '1.2.3-beta.3',
downloadPath: '/tmp/open-design-updater/Open Design Beta.dmg',
enabled: true,
mode: 'package-launcher',
platform: 'darwin',
state: 'downloaded',
supported: true,
...overrides,
};
}
describe('web updater model', () => {
let restoreHost: (() => void) | null = null;
afterEach(() => {
restoreHost?.();
restoreHost = null;
});
it('treats missing host capabilities as the web environment', () => {
const model = deriveUpdaterModel(null, { hostAvailable: false });
expect(model.environment).toBe('web');
expect(model.shouldPrompt).toBe(false);
expect(model.shouldShowControl).toBe(false);
expect(model.canOpenInstaller).toBe(false);
});
it('derives a desktop prompt only after a compatible installer is downloaded', () => {
const model = deriveUpdaterModel(downloadedStatus(), { hostAvailable: true });
expect(model.environment).toBe('desktop');
expect(model.shouldPrompt).toBe(true);
expect(model.hasDownloadedInstaller).toBe(true);
expect(model.canOpenInstaller).toBe(true);
expect(model.shouldShowControl).toBe(true);
expect(model.promptKey).toContain('1.2.3-beta.4');
});
it('derives left-rail download progress from updater snapshots', () => {
const model = deriveUpdaterModel(
downloadedStatus({
progress: {
receivedBytes: 25,
totalBytes: 100,
},
state: 'downloading',
}),
{ hostAvailable: true },
);
expect(model.busy).toBe(true);
expect(model.shouldShowControl).toBe(true);
expect(model.downloadProgress).toEqual({
percent: 25,
receivedBytes: 25,
totalBytes: 100,
});
});
it('keeps the downloaded installer visible while a newer incoming download reports progress', () => {
const model = deriveUpdaterModel(
downloadedStatus({
incoming: {
arch: 'arm64',
artifact: {
name: 'Open Design Beta 1.2.3-beta.5.dmg',
platformKey: 'macAppleSilicon',
type: 'dmg',
url: 'https://fixture.test/Open Design Beta 1.2.3-beta.5.dmg',
},
channel: 'beta',
key: '1.2.3-beta.5-mac-arm64',
progress: {
receivedBytes: 64,
totalBytes: 256,
},
startedAt: '2026-05-19T00:00:00.000Z',
version: '1.2.3-beta.5',
},
state: 'downloaded',
}),
{ hostAvailable: true },
);
expect(model.busy).toBe(false);
expect(model.hasDownloadedInstaller).toBe(true);
expect(model.shouldPrompt).toBe(true);
expect(model.downloadProgress).toEqual({
percent: 25,
receivedBytes: 64,
totalBytes: 256,
});
});
it('does not keep prompting after the installer has been opened', () => {
const model = deriveUpdaterModel(
downloadedStatus({
installResult: {
dryRun: true,
openedAt: '2026-05-19T00:00:00.000Z',
path: '/tmp/open-design-updater/Open Design Beta.dmg',
},
}),
{ hostAvailable: true },
);
expect(model.installerOpened).toBe(true);
expect(model.canQuitAfterInstallerOpen).toBe(true);
expect(model.shouldPrompt).toBe(false);
});
it('routes status, install, and quit requests through host helpers with flexible payloads', async () => {
const status = downloadedStatus();
const statusFn = vi.fn(async () => status);
const install = vi.fn(async () => downloadedStatus({
installResult: {
dryRun: true,
openedAt: '2026-05-19T00:00:00.000Z',
path: status.downloadPath ?? '/tmp/open-design-updater/Open Design Beta.dmg',
},
}));
const quit = vi.fn(async () => ({ ok: true as const }));
restoreHost = installMockOpenDesignHost({
host: {
updater: {
install,
quit,
status: statusFn,
},
},
});
await expect(readUpdaterStatus({ payload: { source: 'test-status' } })).resolves.toMatchObject({
ok: true,
model: { shouldPrompt: true },
});
await expect(openUpdaterInstaller({ payload: { source: 'test-popup' } })).resolves.toMatchObject({
ok: true,
model: { installerOpened: true, shouldPrompt: false },
});
await expect(quitAfterUpdaterInstallerOpen({ payload: { source: 'test-quit' } })).resolves.toEqual({
ok: true,
});
expect(statusFn).toHaveBeenCalledWith({ payload: { source: 'test-status' } });
expect(install).toHaveBeenCalledWith({ payload: { source: 'test-popup' } });
expect(quit).toHaveBeenCalledWith({ payload: { source: 'test-quit' } });
});
});

View file

@ -33,6 +33,27 @@ const healthExpression = `
};
})()
`;
const updaterPopupExpression = `
(() => {
const popup = document.querySelector('[data-testid="updater-popup"]');
const button = document.querySelector('[data-testid="updater-install-button"]');
return {
installButtonVisible: button instanceof HTMLButtonElement && !button.disabled,
text: popup?.textContent?.trim() ?? null,
title: popup?.querySelector('h2')?.textContent?.trim() ?? null,
visible: popup instanceof HTMLElement,
};
})()
`;
const clickUpdaterInstallExpression = `
(() => {
const button = document.querySelector('[data-testid="updater-install-button"]');
if (!(button instanceof HTMLButtonElement)) return { clicked: false, reason: 'missing-install-button' };
if (button.disabled) return { clicked: false, reason: 'install-button-disabled' };
button.click();
return { clicked: true };
})()
`;
type DesktopStatus = {
state?: string;
@ -123,6 +144,18 @@ type HealthEvalValue = {
title: string;
};
type UpdaterPopupEvalValue = {
installButtonVisible: boolean;
text: string | null;
title: string | null;
visible: boolean;
};
type UpdaterClickEvalValue = {
clicked: boolean;
reason?: string;
};
const shouldRunPackagedMacSmoke = process.platform === 'darwin' && process.env.OD_PACKAGED_E2E_MAC === '1';
const macDescribe = shouldRunPackagedMacSmoke ? describe : describe.skip;
const shouldRunDesktopMacSmoke = process.platform === 'darwin' && process.env.OD_DESKTOP_SMOKE === '1';
@ -151,7 +184,7 @@ macDescribe('packaged mac runtime smoke', () => {
process.env.OD_UPDATE_METADATA_URL = updaterFixture.info.metadataUrl;
process.env.OD_UPDATE_CURRENT_VERSION = '99.0.0-beta.0';
process.env.OD_UPDATE_OPEN_DRY_RUN = '1';
process.env.OD_UPDATE_AUTO_CHECK = '0';
process.env.OD_UPDATE_AUTO_CHECK = '1';
const start = await runToolsPackJson<MacStartResult>('start');
started = true;
@ -180,15 +213,26 @@ macDescribe('packaged mac runtime smoke', () => {
expect(value.health.ok).toBe(true);
expect(value.health.version).toEqual(expect.any(String));
const updateStatus = await runToolsPackJson<MacInspectResult>('inspect', ['--update-action', 'check']);
const popup = await waitForUpdaterPopup();
expect(popup.visible).toBe(true);
expect(popup.title).toBe('Update ready');
expect(popup.installButtonVisible).toBe(true);
expect(popup.text ?? '').toContain(updaterFixture.info.version);
const updateStatus = await runToolsPackJson<MacInspectResult>('inspect', ['--update-action', 'status']);
expect(updateStatus.update?.state).toBe('downloaded');
expect(updateStatus.update?.channel).toBe('beta');
expect(updateStatus.update?.currentVersion).toBe('99.0.0-beta.0');
expect(updateStatus.update?.availableVersion).toBe(updaterFixture.info.version);
expectPathInside(updateStatus.update?.downloadPath ?? '', join(runtimeNamespaceRoot, 'updates'));
const updateInstall = await runToolsPackJson<MacInspectResult>('inspect', ['--update-action', 'install']);
const clickInstall = await runToolsPackJson<MacInspectResult>('inspect', ['--expr', clickUpdaterInstallExpression]);
const clickValue = assertUpdaterClickEvalValue(clickInstall.eval?.value);
expect(clickValue.clicked).toBe(true);
const updateInstall = await waitForUpdaterInstallerOpened();
expect(updateInstall.update?.state).toBe('downloaded');
expect(updateInstall.update?.installResult?.dryRun).toBe(true);
expectPathInside(updateInstall.update?.installResult?.path ?? '', join(runtimeNamespaceRoot, 'updates'));
await mkdir(dirname(screenshotPath), { recursive: true });
const screenshot = await runToolsPackJson<MacInspectResult>('inspect', ['--path', screenshotPath]);
@ -232,6 +276,11 @@ macDescribe('packaged mac runtime smoke', () => {
},
stop,
uninstall,
update: {
popup,
status: updateStatus.update,
install: updateInstall.update,
},
});
passed = true;
} finally {
@ -1592,6 +1641,47 @@ async function waitForHealthyDesktop(): Promise<MacInspectResult> {
throw new Error(`packaged mac runtime did not become healthy: ${formatUnknown(lastResult)}`);
}
async function waitForUpdaterPopup(): Promise<UpdaterPopupEvalValue> {
const timeoutMs = 90_000;
const startedAt = Date.now();
let lastResult: unknown = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const inspect = await runToolsPackJson<MacInspectResult>('inspect', ['--expr', updaterPopupExpression]);
lastResult = inspect;
if (inspect.status?.state === 'running' && inspect.eval?.ok === true) {
const value = asUpdaterPopupEvalValue(inspect.eval.value);
if (value?.visible === true && value.installButtonVisible === true) return value;
}
} catch (error) {
lastResult = error;
}
await delay(1000);
}
throw new Error(`packaged mac updater popup did not appear: ${formatUnknown(lastResult)}`);
}
async function waitForUpdaterInstallerOpened(): Promise<MacInspectResult> {
const timeoutMs = 60_000;
const startedAt = Date.now();
let lastResult: unknown = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const inspect = await runToolsPackJson<MacInspectResult>('inspect', ['--update-action', 'status']);
lastResult = inspect;
if (inspect.update?.installResult?.path != null) return inspect;
} catch (error) {
lastResult = error;
}
await delay(1000);
}
throw new Error(`packaged mac updater did not observe installer open: ${formatUnknown(lastResult)}`);
}
function assertLogPathsAndContent(result: LogsResult): void {
expect(result.namespace).toBe(namespace);
for (const app of ['desktop', 'web', 'daemon']) {
@ -1637,6 +1727,14 @@ function assertHealthEvalValue(value: unknown): HealthEvalValue {
return normalized;
}
function assertUpdaterClickEvalValue(value: unknown): UpdaterClickEvalValue {
const normalized = asUpdaterClickEvalValue(value);
if (normalized == null) {
throw new Error(`unexpected updater click eval value: ${formatUnknown(value)}`);
}
return normalized;
}
function asHealthEvalValue(value: unknown): HealthEvalValue | null {
if (!isRecord(value)) return null;
if (typeof value.href !== 'string' || typeof value.status !== 'number' || typeof value.title !== 'string') return null;
@ -1644,6 +1742,22 @@ function asHealthEvalValue(value: unknown): HealthEvalValue | null {
return value as HealthEvalValue;
}
function asUpdaterPopupEvalValue(value: unknown): UpdaterPopupEvalValue | null {
if (!isRecord(value)) return null;
if (typeof value.visible !== 'boolean') return null;
if (typeof value.installButtonVisible !== 'boolean') return null;
if (value.title != null && typeof value.title !== 'string') return null;
if (value.text != null && typeof value.text !== 'string') return null;
return value as UpdaterPopupEvalValue;
}
function asUpdaterClickEvalValue(value: unknown): UpdaterClickEvalValue | null {
if (!isRecord(value)) return null;
if (typeof value.clicked !== 'boolean') return null;
if (value.reason != null && typeof value.reason !== 'string') return null;
return value as UpdaterClickEvalValue;
}
function expectPathInside(filePath: string, expectedRoot: string): void {
const normalizedPath = resolve(filePath);
const normalizedRoot = resolve(expectedRoot);

View file

@ -48,6 +48,141 @@ export type OpenDesignHostPdfPrintOptions = {
deck?: boolean;
};
export const OPEN_DESIGN_HOST_UPDATER_ACTIONS = Object.freeze({
CHECK: "check",
DOWNLOAD: "download",
INSTALL: "install",
QUIT: "quit",
STATUS: "status",
} as const);
export type OpenDesignHostUpdaterAction =
(typeof OPEN_DESIGN_HOST_UPDATER_ACTIONS)[keyof typeof OPEN_DESIGN_HOST_UPDATER_ACTIONS];
type OpenDesignHostUpdaterStatusAction = Exclude<
OpenDesignHostUpdaterAction,
typeof OPEN_DESIGN_HOST_UPDATER_ACTIONS.QUIT
>;
export const OPEN_DESIGN_HOST_UPDATER_STATES = Object.freeze({
AVAILABLE: "available",
CHECKING: "checking",
DOWNLOADED: "downloaded",
DOWNLOADING: "downloading",
ERROR: "error",
IDLE: "idle",
INSTALLING: "installing",
NOT_AVAILABLE: "not-available",
UNSUPPORTED: "unsupported",
} as const);
export type OpenDesignHostUpdaterState =
(typeof OPEN_DESIGN_HOST_UPDATER_STATES)[keyof typeof OPEN_DESIGN_HOST_UPDATER_STATES];
export type OpenDesignHostUpdaterMode = "js-incremental" | "package-launcher";
export type OpenDesignHostUpdaterChannel = "beta" | "stable";
export type OpenDesignHostUpdaterActionOptions = {
payload?: Record<string, unknown>;
};
export type OpenDesignHostUpdaterCapabilitySet = {
canApplyInPlace: boolean;
canDownload: boolean;
canOpenInstaller: boolean;
requiresManualInstall: boolean;
};
export type OpenDesignHostUpdaterPathSnapshot = {
downloadRoot?: string;
manifestPath?: string;
};
export type OpenDesignHostUpdaterChecksumSnapshot = {
algorithm: "sha256" | "sha512";
url?: string;
value?: string;
};
export type OpenDesignHostUpdaterArtifactSnapshot = {
name?: string;
platformKey?: string;
size?: number;
type?: string;
url: string;
};
export type OpenDesignHostUpdaterProgressSnapshot = {
receivedBytes: number;
totalBytes?: number;
};
export type OpenDesignHostUpdaterErrorSnapshot = {
code: string;
details?: unknown;
message: string;
};
export type OpenDesignHostUpdaterInstallResult = {
dryRun?: boolean;
openedAt: string;
path: string;
};
export type OpenDesignHostUpdaterReleaseSnapshot = {
arch: string;
artifact: OpenDesignHostUpdaterArtifactSnapshot;
checksum: OpenDesignHostUpdaterChecksumSnapshot;
channel: OpenDesignHostUpdaterChannel;
downloadedAt: string;
key: string;
metadata?: Record<string, unknown>;
path: string;
platformKey: string;
version: string;
};
export type OpenDesignHostUpdaterIncomingSnapshot = {
arch: string;
artifact: OpenDesignHostUpdaterArtifactSnapshot;
channel: OpenDesignHostUpdaterChannel;
key?: string;
metadata?: Record<string, unknown>;
progress?: OpenDesignHostUpdaterProgressSnapshot;
startedAt: string;
version: string;
};
export type OpenDesignHostUpdaterStatusSnapshot = {
active?: OpenDesignHostUpdaterReleaseSnapshot;
arch: string;
artifact?: OpenDesignHostUpdaterArtifactSnapshot;
artifactUrl?: string;
availableVersion?: string;
capabilities: OpenDesignHostUpdaterCapabilitySet;
channel: OpenDesignHostUpdaterChannel;
checksum?: OpenDesignHostUpdaterChecksumSnapshot;
currentVersion: string;
downloadPath?: string;
enabled: boolean;
error?: OpenDesignHostUpdaterErrorSnapshot;
incoming?: OpenDesignHostUpdaterIncomingSnapshot;
installResult?: OpenDesignHostUpdaterInstallResult;
lastCheckedAt?: string;
metadata?: Record<string, unknown>;
mode: OpenDesignHostUpdaterMode;
paths?: OpenDesignHostUpdaterPathSnapshot;
platform: string;
progress?: OpenDesignHostUpdaterProgressSnapshot;
state: OpenDesignHostUpdaterState;
supported: boolean;
};
export type OpenDesignHostUpdaterResult =
| { ok: true; status: OpenDesignHostUpdaterStatusSnapshot }
| OpenDesignHostFailure;
export type OpenDesignHostUpdaterStatusListener = (status: OpenDesignHostUpdaterStatusSnapshot) => void;
export type OpenDesignHostBridge = {
client: OpenDesignHostClient;
pdf: {
@ -63,6 +198,14 @@ export type OpenDesignHostBridge = {
openExternal(url: string): Promise<OpenDesignHostActionResult>;
openPath(projectId: string): Promise<OpenDesignHostActionResult>;
};
updater: {
check(options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostUpdaterStatusSnapshot>;
download(options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostUpdaterStatusSnapshot>;
install(options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostUpdaterStatusSnapshot>;
quit(options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostActionResult>;
status(options?: OpenDesignHostUpdaterActionOptions): Promise<OpenDesignHostUpdaterStatusSnapshot>;
subscribe(listener: OpenDesignHostUpdaterStatusListener): () => void;
};
version: typeof OPEN_DESIGN_HOST_VERSION;
};
@ -105,6 +248,19 @@ export function isOpenDesignHostBridge(value: unknown): value is OpenDesignHostB
const pet = value.pet;
if (!isRecord(pet) || !hasFunction(pet, "setVisible")) return false;
const updater = value.updater;
if (
!isRecord(updater) ||
!hasFunction(updater, "status") ||
!hasFunction(updater, "check") ||
!hasFunction(updater, "download") ||
!hasFunction(updater, "install") ||
!hasFunction(updater, "quit") ||
!hasFunction(updater, "subscribe")
) {
return false;
}
return true;
}
@ -230,3 +386,74 @@ export function setHostPetVisible(visible: boolean, scope: OpenDesignHostGlobalS
return unavailable(error instanceof Error ? error.message : String(error));
}
}
async function runHostUpdaterAction(
action: OpenDesignHostUpdaterStatusAction,
options?: OpenDesignHostUpdaterActionOptions,
scope: OpenDesignHostGlobalScope = globalThis,
): Promise<OpenDesignHostUpdaterResult> {
const host = getOpenDesignHost(scope);
if (host == null) return unavailable("Open Design host is not available");
try {
return {
ok: true,
status: await host.updater[action](options),
};
} catch (error) {
return unavailable(error instanceof Error ? error.message : String(error));
}
}
export async function getHostUpdaterStatus(
options?: OpenDesignHostUpdaterActionOptions,
scope: OpenDesignHostGlobalScope = globalThis,
): Promise<OpenDesignHostUpdaterResult> {
return await runHostUpdaterAction(OPEN_DESIGN_HOST_UPDATER_ACTIONS.STATUS, options, scope);
}
export async function checkHostUpdater(
options?: OpenDesignHostUpdaterActionOptions,
scope: OpenDesignHostGlobalScope = globalThis,
): Promise<OpenDesignHostUpdaterResult> {
return await runHostUpdaterAction(OPEN_DESIGN_HOST_UPDATER_ACTIONS.CHECK, options, scope);
}
export async function downloadHostUpdater(
options?: OpenDesignHostUpdaterActionOptions,
scope: OpenDesignHostGlobalScope = globalThis,
): Promise<OpenDesignHostUpdaterResult> {
return await runHostUpdaterAction(OPEN_DESIGN_HOST_UPDATER_ACTIONS.DOWNLOAD, options, scope);
}
export async function installHostUpdater(
options?: OpenDesignHostUpdaterActionOptions,
scope: OpenDesignHostGlobalScope = globalThis,
): Promise<OpenDesignHostUpdaterResult> {
return await runHostUpdaterAction(OPEN_DESIGN_HOST_UPDATER_ACTIONS.INSTALL, options, scope);
}
export async function quitHostAfterUpdaterInstallerOpen(
options?: OpenDesignHostUpdaterActionOptions,
scope: OpenDesignHostGlobalScope = globalThis,
): Promise<OpenDesignHostActionResult> {
const host = getOpenDesignHost(scope);
if (host == null) return unavailable("Open Design host is not available");
try {
return await host.updater.quit(options);
} catch (error) {
return unavailable(error instanceof Error ? error.message : String(error));
}
}
export function subscribeHostUpdater(
listener: OpenDesignHostUpdaterStatusListener,
scope: OpenDesignHostGlobalScope = globalThis,
): () => void {
const host = getOpenDesignHost(scope);
if (host == null) return () => undefined;
try {
return host.updater.subscribe(listener);
} catch {
return () => undefined;
}
}

View file

@ -3,14 +3,16 @@ import {
OPEN_DESIGN_HOST_VERSION,
type OpenDesignHostBridge,
type OpenDesignHostGlobalScope,
type OpenDesignHostUpdaterStatusSnapshot,
} from "./index.js";
export type MockOpenDesignHost = Partial<Omit<OpenDesignHostBridge, "client" | "pdf" | "pet" | "project" | "shell">> & {
export type MockOpenDesignHost = Partial<Omit<OpenDesignHostBridge, "client" | "pdf" | "pet" | "project" | "shell" | "updater">> & {
client?: Partial<OpenDesignHostBridge["client"]>;
pdf?: Partial<OpenDesignHostBridge["pdf"]>;
pet?: Partial<OpenDesignHostBridge["pet"]>;
project?: Partial<OpenDesignHostBridge["project"]>;
shell?: Partial<OpenDesignHostBridge["shell"]>;
updater?: Partial<OpenDesignHostBridge["updater"]>;
};
export type MockOpenDesignHostOptions = {
@ -19,6 +21,22 @@ export type MockOpenDesignHostOptions = {
};
function defaultHost(): OpenDesignHostBridge {
const updaterStatus: OpenDesignHostUpdaterStatusSnapshot = {
arch: "arm64",
capabilities: {
canApplyInPlace: false,
canDownload: true,
canOpenInstaller: true,
requiresManualInstall: true,
},
channel: "beta",
currentVersion: "1.0.0-beta.0",
enabled: true,
mode: "package-launcher",
platform: "darwin",
state: "idle",
supported: true,
};
return {
version: OPEN_DESIGN_HOST_VERSION,
client: {
@ -43,6 +61,14 @@ function defaultHost(): OpenDesignHostBridge {
pet: {
setVisible: () => undefined,
},
updater: {
check: async () => updaterStatus,
download: async () => updaterStatus,
install: async () => updaterStatus,
quit: async () => ({ ok: true }),
status: async () => updaterStatus,
subscribe: () => () => undefined,
},
};
}
@ -56,6 +82,7 @@ export function createMockOpenDesignHost(overrides: MockOpenDesignHost = {}): Op
project: { ...base.project, ...overrides.project },
pdf: { ...base.pdf, ...overrides.pdf },
pet: { ...base.pet, ...overrides.pet },
updater: { ...base.updater, ...overrides.updater },
};
}

View file

@ -7,8 +7,11 @@ import { describe, expect, it, vi } from "vitest";
import {
OPEN_DESIGN_HOST_GLOBAL,
OPEN_DESIGN_HOST_VERSION,
checkHostUpdater,
detectOpenDesignHostClientType,
getHostUpdaterStatus,
getOpenDesignHost,
installHostUpdater,
isOpenDesignHostAvailable,
isOpenDesignHostBridge,
normalizeOpenDesignHostProjectImportResult,
@ -16,7 +19,9 @@ import {
pickAndImportHostProject,
printHostPdf,
openHostProjectPath,
quitHostAfterUpdaterInstallerOpen,
setHostPetVisible,
subscribeHostUpdater,
} from "../src/index.js";
import { createMockOpenDesignHost, installMockOpenDesignHost } from "../src/testing.js";
@ -65,6 +70,10 @@ describe("open-design host contract", () => {
...createMockOpenDesignHost(),
shell: { openExternal: async () => ({ ok: true }) },
})).toBe(false);
expect(isOpenDesignHostBridge({
...createMockOpenDesignHost(),
updater: { status: async () => createMockOpenDesignHost().updater.status() },
})).toBe(false);
});
it("reads the bridge through the package-owned global accessor", () => {
@ -188,6 +197,77 @@ describe("open-design host contract", () => {
expect(setVisible).toHaveBeenCalledWith(true);
});
it("routes updater status, actions, and subscriptions through package-owned helpers", async () => {
const status = {
arch: "arm64",
availableVersion: "1.0.1-beta.1",
capabilities: {
canApplyInPlace: false,
canDownload: true,
canOpenInstaller: true,
requiresManualInstall: true,
},
channel: "beta" as const,
currentVersion: "1.0.0-beta.0",
downloadPath: "/tmp/Open Design Beta.dmg",
enabled: true,
mode: "package-launcher" as const,
platform: "darwin",
state: "downloaded" as const,
supported: true,
};
const check = vi.fn(async () => status);
const install = vi.fn(async () => status);
const quit = vi.fn(async () => ({ ok: true as const }));
const statusFn = vi.fn(async () => status);
const unsubscribe = vi.fn();
const subscribe = vi.fn(() => unsubscribe);
const scope: Record<string, unknown> = {};
scope[OPEN_DESIGN_HOST_GLOBAL] = createMockOpenDesignHost({
updater: { check, install, quit, status: statusFn, subscribe },
});
await expect(getHostUpdaterStatus({ payload: { source: "mount" } }, scope)).resolves.toEqual({
ok: true,
status,
});
await expect(checkHostUpdater({ payload: { source: "button" } }, scope)).resolves.toEqual({
ok: true,
status,
});
await expect(installHostUpdater({ payload: { source: "popup" } }, scope)).resolves.toEqual({
ok: true,
status,
});
await expect(quitHostAfterUpdaterInstallerOpen({ payload: { source: "opened-popup" } }, scope)).resolves.toEqual({
ok: true,
});
const listener = vi.fn();
expect(subscribeHostUpdater(listener, scope)).toBe(unsubscribe);
expect(statusFn).toHaveBeenCalledWith({ payload: { source: "mount" } });
expect(check).toHaveBeenCalledWith({ payload: { source: "button" } });
expect(install).toHaveBeenCalledWith({ payload: { source: "popup" } });
expect(quit).toHaveBeenCalledWith({ payload: { source: "opened-popup" } });
expect(subscribe).toHaveBeenCalledWith(listener);
});
it("wraps updater action throws into structured failures", async () => {
const scope: Record<string, unknown> = {};
scope[OPEN_DESIGN_HOST_GLOBAL] = createMockOpenDesignHost({
updater: {
check: vi.fn(async () => {
throw new Error("updater failed");
}),
},
});
await expect(checkHostUpdater(undefined, scope)).resolves.toEqual({
ok: false,
reason: "updater failed",
});
});
it("installs and restores test hosts without exposing callers to the global key", () => {
const scope: Record<string, unknown> = {};
const restore = installMockOpenDesignHost({ scope });

View file

@ -268,7 +268,32 @@ export type DesktopUpdateInstallResult = {
path: string;
};
export type DesktopUpdateReleaseSnapshot = {
arch: string;
artifact: DesktopUpdateArtifactSnapshot;
checksum: DesktopUpdateChecksumSnapshot;
channel: DesktopUpdateChannel;
downloadedAt: string;
key: string;
metadata?: Record<string, unknown>;
path: string;
platformKey: string;
version: string;
};
export type DesktopUpdateIncomingSnapshot = {
arch: string;
artifact: DesktopUpdateArtifactSnapshot;
channel: DesktopUpdateChannel;
key?: string;
metadata?: Record<string, unknown>;
progress?: DesktopUpdateProgressSnapshot;
startedAt: string;
version: string;
};
export type DesktopUpdateStatusSnapshot = {
active?: DesktopUpdateReleaseSnapshot;
arch: string;
artifact?: DesktopUpdateArtifactSnapshot;
artifactUrl?: string;
@ -280,6 +305,7 @@ export type DesktopUpdateStatusSnapshot = {
downloadPath?: string;
enabled: boolean;
error?: DesktopUpdateErrorSnapshot;
incoming?: DesktopUpdateIncomingSnapshot;
installResult?: DesktopUpdateInstallResult;
lastCheckedAt?: string;
metadata?: Record<string, unknown>;