mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Add desktop updater UI flow (#2270)
This commit is contained in:
parent
e94663bfbd
commit
ad37fd30cf
19 changed files with 2587 additions and 227 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"');
|
||||
|
|
|
|||
61
apps/desktop/tests/main/updater-host-boundary.test.ts
Normal file
61
apps/desktop/tests/main/updater-host-boundary.test.ts
Normal 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()");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
216
apps/web/src/components/UpdaterPopup.tsx
Normal file
216
apps/web/src/components/UpdaterPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
178
apps/web/src/lib/updater.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
179
apps/web/tests/components/UpdaterPopup.test.tsx
Normal file
179
apps/web/tests/components/UpdaterPopup.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
175
apps/web/tests/lib/updater.test.ts
Normal file
175
apps/web/tests/lib/updater.test.ts
Normal 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' } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue