diff --git a/AGENTS.md b/AGENTS.md index 945f85a8b4..aa541f6b89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ This file is the single source of truth for agents entering this repository. Rea - `tools/dev` is the local development lifecycle control plane. - `tools/pack` is the local packaged build/start/stop/logs control plane and mac beta release artifact preparation surface. - `tools/pr` is the maintainer PR-duty control plane: a thin `gh` wrapper that encodes this repo's review-lane derivation, forbidden-surface flags, lane checklists, and validation-command suggestions. +- `tools/serve` is the local fixture-service control plane; first service is `tools-serve start updater` for deterministic updater metadata and artifacts. - `e2e` owns user-level end-to-end smoke tests and Playwright UI automation; read `e2e/AGENTS.md` before editing its tests or commands. ## Inactive or placeholder directories @@ -47,7 +48,7 @@ This file is the single source of truth for agents entering this repository. Rea ## Root command boundary -- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, `pnpm tools-pack`, and `pnpm tools-pr`. +- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, `pnpm tools-pack`, `pnpm tools-pr`, and `pnpm tools-serve`. - Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter ...`) or tool-scoped (`pnpm tools-pack ...` / `pnpm tools-pr ...`). - Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`. @@ -166,6 +167,7 @@ For a worked example of one full loop (red e2e spec → fix → green), see `e2e ```bash pnpm install pnpm tools-dev +pnpm tools-serve start updater pnpm tools-dev start web pnpm tools-dev run web --daemon-port 17456 --web-port 17573 pnpm tools-dev status --json @@ -199,6 +201,7 @@ pnpm --filter @open-design/desktop build pnpm --filter @open-design/tools-dev build pnpm --filter @open-design/tools-pack build pnpm --filter @open-design/tools-pr build +pnpm --filter @open-design/tools-serve build ``` ```bash diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4a656d272b..1fff970fad 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { app } from "electron"; +import { app, dialog, Menu, shell, type MenuItemConstructorOptions } from "electron"; import { APP_KEYS, @@ -14,6 +14,7 @@ import { type DesktopEvalInput, type DesktopExportPdfInput, type DesktopScreenshotInput, + type DesktopUpdateInput, type RegisterDesktopAuthResult, type SidecarStamp, type WebStatusSnapshot, @@ -30,6 +31,7 @@ import { readProcessStamp } from "@open-design/platform"; import { createDesktopRuntime } from "./runtime.js"; import { attachDesktopProcessErrorFilter } from "./uncaught-exception.js"; +import { createDesktopUpdater, type DesktopUpdater } 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 @@ -72,6 +74,10 @@ export type DesktopMainOptions = { * Node fetch can hit. */ discoverDaemonUrl?: () => Promise; + update?: { + currentVersion?: string | null; + downloadRoot?: string | null; + }; }; function isDirectEntry(): boolean { @@ -118,6 +124,165 @@ function createWebDiscovery(runtime: SidecarRuntimeContext): () => }; } +function buildUpdateMenuItems(updater: DesktopUpdater): MenuItemConstructorOptions[] { + const status = updater.snapshot(); + const busy = status.state === "checking" || status.state === "downloading" || status.state === "installing"; + return [ + { + enabled: status.enabled && !busy, + label: status.state === "downloading" ? "Downloading Update..." : "Check for Updates...", + async click() { + const next = await updater.checkForUpdates(); + await showUpdateResultDialog(updater, next); + }, + }, + { + enabled: status.enabled && status.state === "downloaded", + label: "Install Update...", + async click() { + const next = await updater.installUpdate(); + if (next.state === "error") { + dialog.showErrorBox("Open Design update failed", next.error?.message ?? "Could not open the downloaded installer."); + } + }, + }, + ]; +} + +async function showUpdateResultDialog(updater: DesktopUpdater, status = updater.snapshot()): Promise { + if (!status.enabled) return; + if (status.state === "downloaded") { + const result = await dialog.showMessageBox({ + buttons: ["Install Update", "Later"], + defaultId: 0, + message: "A new Open Design version has been downloaded.", + detail: "Open the installer to update Open Design. You may need to quit the app and replace the existing copy.", + type: "info", + }); + if (result.response === 0) await updater.installUpdate(); + return; + } + if (status.state === "not-available") { + await dialog.showMessageBox({ + buttons: ["OK"], + message: "Open Design is up to date.", + type: "info", + }); + return; + } + if (status.state === "unsupported") { + await dialog.showMessageBox({ + buttons: ["OK"], + detail: status.error?.message, + message: "Updates are not available for this Open Design build.", + type: "info", + }); + return; + } + if (status.state === "error") { + dialog.showErrorBox("Open Design update failed", status.error?.message ?? "Could not check for updates."); + } +} + +function installDesktopMenu(updater: DesktopUpdater): () => void { + const rebuild = () => { + const updateItems = buildUpdateMenuItems(updater); + const template: MenuItemConstructorOptions[] = [ + ...(process.platform === "darwin" + ? [ + { + label: app.name, + submenu: [ + { role: "about" as const }, + { type: "separator" as const }, + ...updateItems, + { type: "separator" as const }, + { role: "services" as const }, + { type: "separator" as const }, + { role: "hide" as const }, + { role: "hideOthers" as const }, + { role: "unhide" as const }, + { type: "separator" as const }, + { role: "quit" as const }, + ], + }, + ] + : [ + { + label: "File", + submenu: [ + ...updateItems, + { type: "separator" as const }, + { role: "quit" as const }, + ], + }, + ]), + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(process.platform === "darwin" + ? [{ type: "separator" as const }, { role: "front" as const }] + : [{ role: "close" as const }]), + ], + }, + { + label: "Help", + submenu: [ + { + label: "Open Design", + click() { + void shell.openExternal("https://github.com/nexu-io/open-design"); + }, + }, + ], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + }; + + rebuild(); + 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; @@ -220,6 +385,17 @@ export async function runDesktopMain( // protection still works) and POSTs once more. registerDesktopAuthWithDaemon: () => registerDesktopAuthWithDaemon(runtime, desktopAuthSecret), }); + 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; @@ -229,6 +405,7 @@ export async function runDesktopMain( 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(); @@ -252,7 +429,7 @@ export async function runDesktopMain( const request = normalizeDesktopSidecarMessage(message); switch (request.type) { case SIDECAR_MESSAGES.STATUS: - return desktop.status(); + return { ...desktop.status(), update: await updater.status() }; case SIDECAR_MESSAGES.EVAL: return await desktop.eval(request.input as DesktopEvalInput); case SIDECAR_MESSAGES.SCREENSHOT: @@ -263,6 +440,8 @@ export async function runDesktopMain( return await desktop.click(request.input as DesktopClickInput); case SIDECAR_MESSAGES.EXPORT_PDF: return await desktop.exportPdf(request.input as DesktopExportPdfInput); + case SIDECAR_MESSAGES.UPDATE: + return await updater.handle((request.input as DesktopUpdateInput).action); case SIDECAR_MESSAGES.SHUTDOWN: setImmediate(() => { shutdownAndExit(); diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts new file mode 100644 index 0000000000..7ac4e44ebd --- /dev/null +++ b/apps/desktop/src/main/updater.ts @@ -0,0 +1,964 @@ +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { + access, + lstat, + mkdir, + readdir, + readFile, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +import { + DESKTOP_UPDATE_CHANNELS, + DESKTOP_UPDATE_MODES, + DESKTOP_UPDATE_STATES, + SIDECAR_SOURCES, + type DesktopUpdateAction, + type DesktopUpdateArtifactSnapshot, + type DesktopUpdateChannel, + type DesktopUpdateChecksumSnapshot, + type DesktopUpdateErrorSnapshot, + type DesktopUpdateMode, + type DesktopUpdateProgressSnapshot, + type DesktopUpdateStatusSnapshot, + type DesktopUpdateState, + type SidecarSource, +} from "@open-design/sidecar-proto"; + +export const DESKTOP_UPDATE_ENV = Object.freeze({ + ARCH: "OD_UPDATE_ARCH", + AUTO_CHECK: "OD_UPDATE_AUTO_CHECK", + AUTO_DOWNLOAD: "OD_UPDATE_AUTO_DOWNLOAD", + AUTO_OPEN: "OD_UPDATE_AUTO_OPEN", + CHANNEL: "OD_UPDATE_CHANNEL", + CURRENT_VERSION: "OD_UPDATE_CURRENT_VERSION", + DOWNLOAD_ROOT: "OD_UPDATE_DOWNLOAD_ROOT", + ENABLED: "OD_UPDATE_ENABLED", + METADATA_URL: "OD_UPDATE_METADATA_URL", + MODE: "OD_UPDATE_MODE", + OPEN_DRY_RUN: "OD_UPDATE_OPEN_DRY_RUN", + PLATFORM: "OD_UPDATE_PLATFORM", +} as const); + +const DEFAULT_RELEASE_ORIGIN = "https://releases.open-design.ai"; +const OWNERSHIP_SENTINEL = ".open-design-updater-root.json"; +const STATE_FILE = "state.json"; +const UPDATE_ROOT_VERSION = 1; + +export type DesktopUpdaterConfigInput = { + appVersion?: string | null; + arch?: string; + currentVersion?: string | null; + downloadRoot?: string | null; + env?: NodeJS.ProcessEnv; + mode?: DesktopUpdateMode; + platform?: string; + runtimeBase?: string | null; + source: SidecarSource; +}; + +export type DesktopUpdaterConfig = { + arch: string; + autoCheck: boolean; + autoDownload: boolean; + autoOpen: boolean; + channel: DesktopUpdateChannel; + currentVersion: string; + downloadRoot: string; + enabled: boolean; + metadataUrl: string; + mode: DesktopUpdateMode; + openDryRun: boolean; + platform: string; + source: SidecarSource; +}; + +export type DesktopUpdaterDeps = { + fetch?: typeof globalThis.fetch; + now?: () => Date; + openPath?: (path: string) => Promise; +}; + +type UpdateCandidate = { + arch: string; + artifact: DesktopUpdateArtifactSnapshot; + checksum: DesktopUpdateChecksumSnapshot; + channel: DesktopUpdateChannel; + metadata: Record; + platformKey: string; + version: string; +}; + +type PersistedUpdateState = { + artifact: DesktopUpdateArtifactSnapshot; + checksum: DesktopUpdateChecksumSnapshot; + channel: DesktopUpdateChannel; + downloadPath: string; + downloadedAt: string; + metadata: Record; + platform: string; + platformKey: string; + verified: true; + version: 1; + updateVersion: string; +}; + +type OwnedRoot = + | { ok: true; manifestPath: string; realRoot: string } + | { error: DesktopUpdateErrorSnapshot; ok: false }; + +type ActionOptions = { + autoDownload?: boolean; +}; + +export type DesktopUpdater = { + checkForUpdates(options?: ActionOptions): Promise; + downloadUpdate(): Promise; + handle(action: DesktopUpdateAction): Promise; + installUpdate(): Promise; + shouldAutoCheck(): boolean; + snapshot(): DesktopUpdateStatusSnapshot; + status(): Promise; + subscribe(listener: () => void): () => void; +}; + +function isTruthyEnv(value: string | undefined): boolean | null { + if (value == null || value.length === 0) return null; + if (value === "1" || value === "true" || value === "yes") return true; + if (value === "0" || value === "false" || value === "no") return false; + throw new Error(`boolean env value must be one of 1/0/true/false/yes/no, got ${value}`); +} + +function normalizeMode(value: string | undefined, fallback: DesktopUpdateMode): DesktopUpdateMode { + if (value == null || value.length === 0) return fallback; + if (value === DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER || value === DESKTOP_UPDATE_MODES.JS_INCREMENTAL) return value; + throw new Error(`unsupported desktop update mode: ${value}`); +} + +function normalizeChannel(value: string | undefined, fallback: DesktopUpdateChannel): DesktopUpdateChannel { + if (value == null || value.length === 0) return fallback; + if (value === DESKTOP_UPDATE_CHANNELS.STABLE || value === DESKTOP_UPDATE_CHANNELS.BETA) return value; + throw new Error(`unsupported desktop update channel: ${value}`); +} + +function defaultMetadataUrl(channel: DesktopUpdateChannel): string { + return `${DEFAULT_RELEASE_ORIGIN}/${channel}/latest/metadata.json`; +} + +function normalizeDownloadRoot(value: string): string { + if (value.includes("\0")) throw new Error("update download root must not contain null bytes"); + if (!isAbsolute(value)) throw new Error(`update download root must be absolute: ${value}`); + return resolve(value); +} + +export function resolveDesktopUpdaterConfig(input: DesktopUpdaterConfigInput): DesktopUpdaterConfig { + const env = input.env ?? process.env; + const mode = normalizeMode(env[DESKTOP_UPDATE_ENV.MODE], input.mode ?? DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER); + const defaultEnabled = input.source === SIDECAR_SOURCES.PACKAGED; + const enabled = isTruthyEnv(env[DESKTOP_UPDATE_ENV.ENABLED]) ?? defaultEnabled; + const runtimeBase = input.runtimeBase == null ? process.cwd() : input.runtimeBase; + const downloadRoot = normalizeDownloadRoot( + env[DESKTOP_UPDATE_ENV.DOWNLOAD_ROOT] ?? + input.downloadRoot ?? + join(resolve(runtimeBase), "updates"), + ); + const currentVersion = + env[DESKTOP_UPDATE_ENV.CURRENT_VERSION] ?? + input.currentVersion ?? + input.appVersion ?? + "0.0.0"; + const channel = normalizeChannel(env[DESKTOP_UPDATE_ENV.CHANNEL], defaultChannelForVersion(currentVersion)); + + return { + arch: env[DESKTOP_UPDATE_ENV.ARCH] ?? input.arch ?? process.arch, + autoCheck: isTruthyEnv(env[DESKTOP_UPDATE_ENV.AUTO_CHECK]) ?? enabled, + autoDownload: isTruthyEnv(env[DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]) ?? true, + autoOpen: isTruthyEnv(env[DESKTOP_UPDATE_ENV.AUTO_OPEN]) ?? false, + channel, + currentVersion, + downloadRoot, + enabled, + metadataUrl: env[DESKTOP_UPDATE_ENV.METADATA_URL] ?? defaultMetadataUrl(channel), + mode, + openDryRun: isTruthyEnv(env[DESKTOP_UPDATE_ENV.OPEN_DRY_RUN]) ?? false, + platform: env[DESKTOP_UPDATE_ENV.PLATFORM] ?? input.platform ?? process.platform, + source: input.source, + }; +} + +function capabilitiesFor(status: { mode: DesktopUpdateMode; platform: string; supported: boolean }) { + const packageLauncher = status.mode === DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER && status.platform === "darwin" && status.supported; + return { + canApplyInPlace: false, + canDownload: packageLauncher, + canOpenInstaller: packageLauncher, + requiresManualInstall: packageLauncher, + }; +} + +function createError(code: string, message: string, details?: unknown): DesktopUpdateErrorSnapshot { + return { + code, + ...(details === undefined ? {} : { details }), + message, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +function stringField(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberField(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function objectField(record: Record, key: string): Record | null { + const value = record[key]; + return isRecord(value) ? value : null; +} + +function sanitizePathSegment(value: string): string { + return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "update"; +} + +function extensionForArtifact(name: string | undefined, type: string): string { + const ext = name == null ? "" : extname(name).toLowerCase(); + if (ext === ".dmg" || ext === ".zip" || ext === ".exe" || ext === ".appimage") return ext; + if (type === "dmg") return ".dmg"; + if (type === "zip") return ".zip"; + if (type === "installer") return ".exe"; + return ".bin"; +} + +function artifactFileName(candidate: UpdateCandidate): string { + const ext = extensionForArtifact(candidate.artifact.name, candidate.artifact.type ?? "artifact"); + return [ + "open-design", + sanitizePathSegment(candidate.version), + sanitizePathSegment(candidate.platformKey), + sanitizePathSegment(candidate.arch), + sanitizePathSegment(candidate.artifact.type ?? "artifact"), + ].join("-") + ext; +} + +function containsPath(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel)); +} + +async function writeJson(path: string, payload: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await rename(tmp, path); +} + +async function readJson(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")) as T; + } catch { + return null; + } +} + +async function directoryIsEmpty(path: string): Promise { + const entries = await readdir(path); + return entries.length === 0; +} + +async function ensureOwnedUpdateRoot(config: DesktopUpdaterConfig): Promise { + const root = normalizeDownloadRoot(config.downloadRoot); + try { + await mkdir(root, { recursive: true }); + const rootEntry = await lstat(root); + if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) { + return { + ok: false, + error: createError("update-root-not-owned", `update root is not an owned directory: ${root}`), + }; + } + const realRoot = await realpath(root); + const sentinelPath = join(realRoot, OWNERSHIP_SENTINEL); + const manifestPath = join(realRoot, STATE_FILE); + const sentinel = await readJson<{ namespace?: string; version?: number }>(sentinelPath); + if (sentinel != null) { + if (sentinel.version !== UPDATE_ROOT_VERSION) { + return { + ok: false, + error: createError("update-root-version-mismatch", `update root has unsupported ownership marker version at ${sentinelPath}`), + }; + } + return { ok: true, manifestPath, realRoot }; + } + + if (!(await directoryIsEmpty(realRoot))) { + return { + ok: false, + error: createError( + "update-root-not-owned", + `update root is not empty and has no Open Design updater ownership marker: ${realRoot}`, + ), + }; + } + + await writeJson(sentinelPath, { + createdAt: new Date().toISOString(), + owner: "open-design-updater", + source: config.source, + version: UPDATE_ROOT_VERSION, + }); + return { ok: true, manifestPath, realRoot }; + } catch (error) { + return { + ok: false, + error: createError("update-root-unavailable", error instanceof Error ? error.message : String(error)), + }; + } +} + +type ParsedComparableVersion = { + nums: [number, number, number]; + pre: string[]; +}; + +function numberPart(value: string | undefined): number { + return value != null && /^[0-9]+$/.test(value) ? Number(value) : 0; +} + +function parseComparableVersion(value: string): ParsedComparableVersion { + const cleaned = value.trim().replace(/^v/i, "").split("+", 1)[0] ?? ""; + const nightlyMatch = /^(\d+)\.(\d+)\.(\d+)\.nightly\.(\d+)$/i.exec(cleaned); + if (nightlyMatch?.[1] != null && nightlyMatch[2] != null && nightlyMatch[3] != null && nightlyMatch[4] != null) { + return { + nums: [Number(nightlyMatch[1]), Number(nightlyMatch[2]), Number(nightlyMatch[3])], + pre: ["nightly", nightlyMatch[4]], + }; + } + + const prereleaseSeparator = cleaned.indexOf("-"); + const core = prereleaseSeparator === -1 ? cleaned : cleaned.slice(0, prereleaseSeparator); + const prerelease = prereleaseSeparator === -1 ? "" : cleaned.slice(prereleaseSeparator + 1); + const nums = core.split("."); + return { + nums: [numberPart(nums[0]), numberPart(nums[1]), numberPart(nums[2])], + pre: prerelease.length === 0 ? [] : prerelease.split("."), + }; +} + +function hasCountedPrerelease(version: string): boolean { + const parsed = parseComparableVersion(version); + const last = parsed.pre.at(-1); + return parsed.pre.length >= 2 && last != null && /^[0-9]+$/.test(last); +} + +function defaultChannelForVersion(version: string): DesktopUpdateChannel { + return /(?:^|[-.])beta(?:[-.]|$)/i.test(version) || hasCountedPrerelease(version) + ? DESKTOP_UPDATE_CHANNELS.BETA + : DESKTOP_UPDATE_CHANNELS.STABLE; +} + +function compareIdentifier(a: string, b: string): number { + const aNum = /^[0-9]+$/.test(a) ? Number(a) : null; + const bNum = /^[0-9]+$/.test(b) ? Number(b) : null; + if (aNum != null && bNum != null) return Math.sign(aNum - bNum); + if (aNum != null) return -1; + if (bNum != null) return 1; + return a.localeCompare(b); +} + +export function compareVersions(a: string, b: string): number { + const left = parseComparableVersion(a); + const right = parseComparableVersion(b); + for (let index = 0; index < 3; index += 1) { + const delta = (left.nums[index] ?? 0) - (right.nums[index] ?? 0); + if (delta !== 0) return Math.sign(delta); + } + if (left.pre.length === 0 && right.pre.length === 0) return 0; + if (left.pre.length === 0) return 1; + if (right.pre.length === 0) return -1; + const max = Math.max(left.pre.length, right.pre.length); + for (let index = 0; index < max; index += 1) { + const l = left.pre[index]; + const r = right.pre[index]; + if (l == null) return -1; + if (r == null) return 1; + const delta = compareIdentifier(l, r); + if (delta !== 0) return delta; + } + return 0; +} + +function releaseVersion(metadata: Record): string | null { + return ( + stringField(metadata, "releaseVersion") ?? + stringField(metadata, "betaVersion") ?? + stringField(metadata, "nightlyVersion") ?? + stringField(metadata, "stableVersion") ?? + stringField(metadata, "baseVersion") + ); +} + +function selectedMacPlatformKey(platforms: Record, arch: string): string { + return arch === "x64" ? "macIntel" : "mac"; +} + +function selectUpdateCandidate( + metadata: Record, + config: DesktopUpdaterConfig, +): { candidate: UpdateCandidate; ok: true } | { error: DesktopUpdateErrorSnapshot; ok: false; state: DesktopUpdateState } { + if (config.mode === DESKTOP_UPDATE_MODES.JS_INCREMENTAL) { + return { + ok: false, + state: DESKTOP_UPDATE_STATES.UNSUPPORTED, + error: createError("update-mode-not-implemented", "js-incremental updates are not implemented yet"), + }; + } + if (config.mode !== DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER) { + return { + ok: false, + state: DESKTOP_UPDATE_STATES.UNSUPPORTED, + error: createError("update-mode-unsupported", `unsupported update mode: ${config.mode}`), + }; + } + if (config.platform !== "darwin") { + return { + ok: false, + state: DESKTOP_UPDATE_STATES.UNSUPPORTED, + error: createError("unsupported-platform", "package-launcher updates are currently mac-only"), + }; + } + + const platforms = objectField(metadata, "platforms"); + if (platforms == null) { + return { + ok: false, + state: DESKTOP_UPDATE_STATES.ERROR, + error: createError("metadata-missing-platforms", "release metadata does not include platform artifacts"), + }; + } + const platformKey = selectedMacPlatformKey(platforms, config.arch); + const platform = objectField(platforms, platformKey); + if (platform == null || platform.enabled !== true) { + return { + ok: false, + state: DESKTOP_UPDATE_STATES.ERROR, + error: createError("no-compatible-artifact", `release metadata does not include an enabled ${platformKey} artifact`), + }; + } + const version = releaseVersion(metadata); + if (version == null) { + return { + ok: false, + state: DESKTOP_UPDATE_STATES.ERROR, + error: createError("metadata-missing-version", "release metadata does not include a release version"), + }; + } + const artifacts = objectField(platform, "artifacts"); + const dmg = artifacts == null ? null : objectField(artifacts, "dmg"); + const url = dmg == null ? null : stringField(dmg, "url"); + if (dmg == null || url == null) { + return { + ok: false, + state: DESKTOP_UPDATE_STATES.ERROR, + error: createError("no-compatible-artifact", `release metadata does not include a mac DMG artifact for ${platformKey}`), + }; + } + + const artifact: DesktopUpdateArtifactSnapshot = { + ...(stringField(dmg, "name") == null ? {} : { name: stringField(dmg, "name") as string }), + platformKey, + ...(numberField(dmg, "size") == null ? {} : { size: numberField(dmg, "size") }), + type: "dmg", + url, + }; + const sha256 = stringField(dmg, "sha256") ?? stringField(dmg, "sha256Digest"); + const sha512 = stringField(dmg, "sha512") ?? stringField(dmg, "sha512Digest"); + const checksum: DesktopUpdateChecksumSnapshot = + sha512 != null + ? { algorithm: "sha512", value: sha512 } + : { + algorithm: "sha256", + ...(sha256 == null ? {} : { value: sha256 }), + ...(stringField(dmg, "sha256Url") == null ? {} : { url: stringField(dmg, "sha256Url") as string }), + }; + + return { + ok: true, + candidate: { + arch: stringField(platform, "arch") ?? config.arch, + artifact, + checksum, + channel: config.channel, + metadata, + platformKey, + version, + }, + }; +} + +async function fetchJson(fetchImpl: typeof globalThis.fetch, url: string): Promise> { + const response = await fetchImpl(url); + if (!response.ok) throw new Error(`metadata request returned HTTP ${response.status}`); + const body = await response.json(); + if (!isRecord(body)) throw new Error("metadata response was not a JSON object"); + return body; +} + +function parseChecksumText(text: string, algorithm: "sha256" | "sha512"): string { + const length = algorithm === "sha256" ? 64 : 128; + const match = text.match(new RegExp(`\\b[0-9a-fA-F]{${length}}\\b`)); + if (match == null) throw new Error(`checksum file does not include a ${algorithm} digest`); + return match[0].toLowerCase(); +} + +async function resolveChecksum(fetchImpl: typeof globalThis.fetch, checksum: DesktopUpdateChecksumSnapshot): Promise { + if (checksum.value != null) return checksum; + if (checksum.url == null) throw new Error("artifact checksum is missing"); + const response = await fetchImpl(checksum.url); + if (!response.ok) throw new Error(`checksum request returned HTTP ${response.status}`); + return { + ...checksum, + value: parseChecksumText(await response.text(), checksum.algorithm), + }; +} + +async function hashFile(path: string, algorithm: "sha256" | "sha512"): Promise { + const hash = createHash(algorithm); + await pipeline(createReadStream(path), hash); + return hash.digest("hex"); +} + +async function downloadToFile( + fetchImpl: typeof globalThis.fetch, + url: string, + path: string, + onProgress: (progress: DesktopUpdateProgressSnapshot) => void, +): Promise { + const response = await fetchImpl(url); + if (!response.ok) throw new Error(`artifact request returned HTTP ${response.status}`); + if (response.body == null) throw new Error("artifact response did not include a body"); + await mkdir(dirname(path), { recursive: true }); + const totalRaw = response.headers.get("content-length"); + const parsedTotalBytes = totalRaw == null ? undefined : Number(totalRaw); + const totalBytes = parsedTotalBytes != null && Number.isFinite(parsedTotalBytes) && parsedTotalBytes > 0 + ? parsedTotalBytes + : undefined; + let receivedBytes = 0; + const meter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + receivedBytes += chunk.byteLength; + onProgress({ receivedBytes, ...(totalBytes == null ? {} : { totalBytes }) }); + callback(null, chunk); + }, + }); + await pipeline( + Readable.fromWeb(response.body as never), + meter, + createWriteStream(path, { flags: "wx" }), + ); +} + +async function removeContainedEntry(root: string, path: string): Promise { + const resolved = resolve(path); + if (!containsPath(root, resolved)) return false; + let entry; + try { + entry = await lstat(resolved); + } catch { + return false; + } + if (entry.isSymbolicLink()) return false; + if (entry.isDirectory()) { + const real = await realpath(resolved).catch(() => null); + if (real == null || !containsPath(root, real)) return false; + } + await rm(resolved, { force: true, recursive: true }); + return true; +} + +async function ensureOwnedSubdir(root: string, name: string): Promise { + if (name.length === 0 || name.includes("\0") || /[\\/]/.test(name)) { + throw new Error(`update subdirectory must be a simple path segment: ${name}`); + } + const dir = join(root, name); + if (!containsPath(root, dir)) throw new Error(`update subdirectory escaped update root: ${dir}`); + await mkdir(dir, { recursive: true }); + const entry = await lstat(dir); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error(`update subdirectory is not an owned directory: ${dir}`); + } + const realDir = await realpath(dir); + if (!containsPath(root, realDir)) throw new Error(`update subdirectory realpath escaped update root: ${realDir}`); + return realDir; +} + +async function existingOwnedSubdir(root: string, name: string): Promise { + const dir = join(root, name); + let entry; + try { + entry = await lstat(dir); + } catch { + return null; + } + if (!entry.isDirectory() || entry.isSymbolicLink()) return null; + const realDir = await realpath(dir).catch(() => null); + if (realDir == null || !containsPath(root, realDir)) return null; + return realDir; +} + +async function cleanupOwnedUpdateRoot(root: string, keepPaths: readonly string[]): Promise { + const keep = new Set(keepPaths.map((path) => resolve(path))); + for (const child of ["tmp", "artifacts"]) { + const dir = await existingOwnedSubdir(root, child); + if (dir == null) continue; + const entries = await readdir(dir).catch(() => []); + for (const entry of entries) { + const path = join(dir, entry); + if (keep.has(resolve(path))) continue; + await removeContainedEntry(root, path).catch(() => false); + } + } +} + +function persistedState(value: unknown): PersistedUpdateState | null { + if (!isRecord(value)) return null; + if (value.version !== 1 || value.verified !== true) return null; + if (typeof value.updateVersion !== "string" || typeof value.downloadPath !== "string") return null; + if (typeof value.platform !== "string" || typeof value.platformKey !== "string") return null; + if (value.channel !== DESKTOP_UPDATE_CHANNELS.STABLE && value.channel !== DESKTOP_UPDATE_CHANNELS.BETA) return null; + if (!isRecord(value.metadata) || !isRecord(value.artifact) || !isRecord(value.checksum)) return null; + const artifact = value.artifact as DesktopUpdateArtifactSnapshot; + const checksum = value.checksum as DesktopUpdateChecksumSnapshot; + if (typeof artifact.url !== "string" || artifact.url.length === 0) return null; + if (checksum.algorithm !== "sha256" && checksum.algorithm !== "sha512") return null; + return value as PersistedUpdateState; +} + +export function createDesktopUpdater( + configInput: DesktopUpdaterConfigInput, + deps: DesktopUpdaterDeps = {}, +): DesktopUpdater { + const config = resolveDesktopUpdaterConfig(configInput); + const fetchImpl = deps.fetch ?? globalThis.fetch; + const now = deps.now ?? (() => new Date()); + const openPath = deps.openPath ?? (async () => "openPath is not available"); + const listeners = new Set<() => void>(); + let candidate: UpdateCandidate | null = null; + let downloadPath: string | null = null; + let checksum: DesktopUpdateChecksumSnapshot | null = null; + let metadata: Record | null = null; + let lastCheckedAt: string | undefined; + let installResult: DesktopUpdateStatusSnapshot["installResult"]; + let progress: DesktopUpdateProgressSnapshot | undefined; + let state: DesktopUpdateState = DESKTOP_UPDATE_STATES.IDLE; + let error: DesktopUpdateErrorSnapshot | undefined; + let operation: Promise = Promise.resolve(); + + function supported(): boolean { + return config.enabled && config.mode === DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER && config.platform === "darwin"; + } + + function emit(): void { + for (const listener of listeners) listener(); + } + + function setState(next: DesktopUpdateState, nextError?: DesktopUpdateErrorSnapshot): DesktopUpdateStatusSnapshot { + state = next; + error = nextError; + const status = snapshot(); + emit(); + return status; + } + + function snapshot(): DesktopUpdateStatusSnapshot { + const statusSupported = supported(); + return { + arch: config.arch, + ...(candidate?.artifact == null ? {} : { artifact: candidate.artifact }), + ...(candidate?.artifact.url == null ? {} : { artifactUrl: candidate.artifact.url }), + ...(candidate?.version == null ? {} : { availableVersion: candidate.version }), + capabilities: capabilitiesFor({ mode: config.mode, platform: config.platform, supported: statusSupported }), + channel: config.channel, + ...(checksum == null ? {} : { checksum }), + currentVersion: config.currentVersion, + ...(downloadPath == null ? {} : { downloadPath }), + enabled: config.enabled, + ...(error == null ? {} : { error }), + ...(installResult == null ? {} : { installResult }), + ...(lastCheckedAt == null ? {} : { lastCheckedAt }), + ...(metadata == null ? {} : { metadata }), + mode: config.mode, + paths: { downloadRoot: config.downloadRoot }, + platform: config.platform, + ...(progress == null ? {} : { progress }), + state, + supported: statusSupported, + }; + } + + function unsupportedStatus(): DesktopUpdateStatusSnapshot | null { + if (!config.enabled) { + return setState(DESKTOP_UPDATE_STATES.IDLE); + } + if (config.mode === DESKTOP_UPDATE_MODES.JS_INCREMENTAL) { + return setState( + DESKTOP_UPDATE_STATES.UNSUPPORTED, + createError("update-mode-not-implemented", "js-incremental updates are not implemented yet"), + ); + } + if (config.platform !== "darwin") { + return setState( + DESKTOP_UPDATE_STATES.UNSUPPORTED, + createError("unsupported-platform", "package-launcher updates are currently mac-only"), + ); + } + return null; + } + + async function restoreDownloadedState(): Promise { + const root = await ensureOwnedUpdateRoot(config); + if (!root.ok) return setState(DESKTOP_UPDATE_STATES.ERROR, root.error); + const saved = persistedState(await readJson(root.manifestPath)); + if (saved == null) return null; + const resolvedDownload = resolve(saved.downloadPath); + if (!containsPath(root.realRoot, resolvedDownload)) { + return setState(DESKTOP_UPDATE_STATES.ERROR, createError("download-path-escaped", "saved update path is outside the update root")); + } + try { + await access(resolvedDownload); + const file = await stat(resolvedDownload); + if (!file.isFile()) return null; + } catch { + return null; + } + candidate = { + arch: config.arch, + artifact: saved.artifact, + checksum: saved.checksum, + channel: saved.channel, + metadata: saved.metadata, + platformKey: saved.platformKey, + version: saved.updateVersion, + }; + checksum = saved.checksum; + metadata = saved.metadata; + downloadPath = resolvedDownload; + return setState(DESKTOP_UPDATE_STATES.DOWNLOADED); + } + + async function checkForCandidate(options: ActionOptions = {}): Promise { + const unsupported = unsupportedStatus(); + if (unsupported != null) return unsupported; + setState(DESKTOP_UPDATE_STATES.CHECKING); + try { + const body = await fetchJson(fetchImpl, config.metadataUrl); + lastCheckedAt = now().toISOString(); + metadata = body; + const selected = selectUpdateCandidate(body, config); + if (!selected.ok) return setState(selected.state, selected.error); + if (compareVersions(selected.candidate.version, config.currentVersion) <= 0) { + candidate = null; + checksum = null; + downloadPath = null; + return setState(DESKTOP_UPDATE_STATES.NOT_AVAILABLE); + } + candidate = selected.candidate; + checksum = selected.candidate.checksum; + downloadPath = null; + const available = setState(DESKTOP_UPDATE_STATES.AVAILABLE); + if (options.autoDownload ?? config.autoDownload) return await downloadUpdate(); + return available; + } catch (checkError) { + return setState( + DESKTOP_UPDATE_STATES.ERROR, + createError("metadata-unreachable", checkError instanceof Error ? checkError.message : String(checkError)), + ); + } + } + + async function downloadUpdate(): Promise { + const unsupported = unsupportedStatus(); + if (unsupported != null) return unsupported; + if (candidate == null) { + const checked = await checkForCandidate({ autoDownload: false }); + if (checked.state !== DESKTOP_UPDATE_STATES.AVAILABLE || candidate == null) return checked; + } + const root = await ensureOwnedUpdateRoot(config); + if (!root.ok) return setState(DESKTOP_UPDATE_STATES.ERROR, root.error); + setState(DESKTOP_UPDATE_STATES.DOWNLOADING); + const nextCandidate = candidate; + const outputName = artifactFileName(nextCandidate); + let tmpPath: string | null = null; + try { + const artifactsDir = await ensureOwnedSubdir(root.realRoot, "artifacts"); + const tmpDir = await ensureOwnedSubdir(root.realRoot, "tmp"); + const finalPath = join(artifactsDir, outputName); + tmpPath = join(tmpDir, `${outputName}.${process.pid}.${Date.now()}.download`); + if (!containsPath(root.realRoot, finalPath) || !containsPath(root.realRoot, tmpPath)) { + return setState(DESKTOP_UPDATE_STATES.ERROR, createError("download-path-escaped", "resolved update download path escaped update root")); + } + const resolvedChecksum = await resolveChecksum(fetchImpl, nextCandidate.checksum); + checksum = resolvedChecksum; + await rm(tmpPath, { force: true }); + await downloadToFile(fetchImpl, nextCandidate.artifact.url, tmpPath, (nextProgress) => { + progress = nextProgress; + emit(); + }); + const digest = await hashFile(tmpPath, resolvedChecksum.algorithm); + if (resolvedChecksum.value == null || digest.toLowerCase() !== resolvedChecksum.value.toLowerCase()) { + await rm(tmpPath, { force: true }); + return setState( + DESKTOP_UPDATE_STATES.ERROR, + createError("checksum-mismatch", "downloaded update checksum did not match release metadata", { + actual: digest, + expected: resolvedChecksum.value, + }), + ); + } + await mkdir(dirname(finalPath), { recursive: true }); + await rm(finalPath, { force: true }); + await rename(tmpPath, finalPath); + downloadPath = finalPath; + progress = undefined; + const persisted: PersistedUpdateState = { + artifact: nextCandidate.artifact, + checksum: resolvedChecksum, + channel: nextCandidate.channel, + downloadPath: finalPath, + downloadedAt: now().toISOString(), + metadata: nextCandidate.metadata, + platform: config.platform, + platformKey: nextCandidate.platformKey, + updateVersion: nextCandidate.version, + verified: true, + version: 1, + }; + await writeJson(root.manifestPath, persisted); + await cleanupOwnedUpdateRoot(root.realRoot, [finalPath, root.manifestPath]); + const downloaded = setState(DESKTOP_UPDATE_STATES.DOWNLOADED); + if (config.autoOpen) return await installUpdate(); + return downloaded; + } catch (downloadError) { + if (tmpPath != null) await rm(tmpPath, { force: true }).catch(() => undefined); + return setState( + DESKTOP_UPDATE_STATES.ERROR, + createError("download-failed", downloadError instanceof Error ? downloadError.message : String(downloadError)), + ); + } + } + + async function installUpdate(): Promise { + const unsupported = unsupportedStatus(); + if (unsupported != null) return unsupported; + if (downloadPath == null) { + const restored = await restoreDownloadedState(); + if (restored == null || downloadPath == null) { + return setState(DESKTOP_UPDATE_STATES.ERROR, createError("update-not-downloaded", "no downloaded update package is available")); + } + } + const root = await ensureOwnedUpdateRoot(config); + if (!root.ok) return setState(DESKTOP_UPDATE_STATES.ERROR, root.error); + const resolvedDownload = resolve(downloadPath); + if (!containsPath(root.realRoot, resolvedDownload)) { + return setState(DESKTOP_UPDATE_STATES.ERROR, createError("download-path-escaped", "download path is outside the update root")); + } + setState(DESKTOP_UPDATE_STATES.INSTALLING); + const installChecksum = checksum; + if (installChecksum?.value == null) { + return setState(DESKTOP_UPDATE_STATES.ERROR, createError("checksum-missing", "downloaded update checksum is missing")); + } + let digest: string; + try { + digest = await hashFile(resolvedDownload, installChecksum.algorithm); + } catch (hashError) { + return setState( + DESKTOP_UPDATE_STATES.ERROR, + createError("download-unavailable", hashError instanceof Error ? hashError.message : String(hashError)), + ); + } + if (digest.toLowerCase() !== installChecksum.value.toLowerCase()) { + return setState( + DESKTOP_UPDATE_STATES.ERROR, + createError("checksum-mismatch", "downloaded update checksum changed before install", { + actual: digest, + expected: installChecksum.value, + }), + ); + } + try { + const openedAt = now().toISOString(); + if (!config.openDryRun) { + const openError = await openPath(resolvedDownload); + if (openError.length > 0) { + return setState(DESKTOP_UPDATE_STATES.ERROR, createError("open-installer-failed", openError)); + } + } + installResult = { + ...(config.openDryRun ? { dryRun: true } : {}), + openedAt, + path: resolvedDownload, + }; + return setState(DESKTOP_UPDATE_STATES.DOWNLOADED); + } catch (installError) { + return setState( + DESKTOP_UPDATE_STATES.ERROR, + createError("open-installer-failed", installError instanceof Error ? installError.message : String(installError)), + ); + } + } + + async function serialized(run: () => Promise): Promise { + const next = operation.catch(() => undefined).then(run); + operation = next.catch(() => undefined); + return await next; + } + + return { + checkForUpdates: (options) => serialized(() => checkForCandidate(options)), + downloadUpdate: () => serialized(downloadUpdate), + handle(action) { + switch (action) { + case "status": + return this.status(); + case "check": + return this.checkForUpdates(); + case "download": + return this.downloadUpdate(); + case "install": + return this.installUpdate(); + } + }, + installUpdate: () => serialized(installUpdate), + shouldAutoCheck: () => config.enabled && config.autoCheck, + snapshot, + async status() { + const unsupported = unsupportedStatus(); + if (unsupported != null) return unsupported; + if (state === DESKTOP_UPDATE_STATES.IDLE) { + const restored = await restoreDownloadedState(); + if (restored != null) return restored; + } + return snapshot(); + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/apps/desktop/tests/main/updater.test.ts b/apps/desktop/tests/main/updater.test.ts new file mode 100644 index 0000000000..a516a66958 --- /dev/null +++ b/apps/desktop/tests/main/updater.test.ts @@ -0,0 +1,477 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { readFile, realpath, writeFile } from "node:fs/promises"; +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 { + DESKTOP_UPDATE_CHANNELS, + DESKTOP_UPDATE_STATES, + SIDECAR_SOURCES, +} from "@open-design/sidecar-proto"; + +import { compareVersions, createDesktopUpdater, DESKTOP_UPDATE_ENV, resolveDesktopUpdaterConfig } from "../../src/main/updater.js"; + +type FixtureServer = { + close: () => Promise; + metadataUrl: string; +}; + +function prereleaseCounterParts(version: string): { baseVersion: string; number: number } | null { + const prerelease = /^(\d+\.\d+\.\d+)-.+\.(\d+)$/.exec(version); + if (prerelease?.[1] != null && prerelease[2] != null) { + return { baseVersion: prerelease[1], number: Number(prerelease[2]) }; + } + const nightly = /^(\d+\.\d+\.\d+)\.nightly\.(\d+)$/i.exec(version); + if (nightly?.[1] != null && nightly[2] != null) { + return { baseVersion: nightly[1], number: Number(nightly[2]) }; + } + return null; +} + +async function createUpdaterFixture(options: { + artifactBody?: string; + channel?: "stable" | "beta"; + version?: string; +} = {}): Promise { + const version = options.version ?? "1.0.1"; + const channel = options.channel ?? "stable"; + const artifactBody = options.artifactBody ?? "open design updater fixture"; + const digest = createHash("sha256").update(artifactBody).digest("hex"); + const server = createServer((request, response) => { + const url = request.url ?? "/"; + if (url === "/metadata.json") { + response.setHeader("content-type", "application/json"); + const betaVersion = prereleaseCounterParts(version); + response.end(JSON.stringify({ + channel, + ...(channel === "beta" + ? { + baseVersion: betaVersion?.baseVersion, + betaNumber: betaVersion?.number, + betaVersion: version, + } + : { + baseVersion: version, + releaseVersion: version, + stableVersion: version, + }), + platforms: { + mac: { + arch: "arm64", + enabled: true, + artifacts: { + dmg: { + name: `open-design-${version}-mac-arm64.dmg`, + sha256Url: `http://${serverAddress(server)}/artifact.dmg.sha256`, + size: Buffer.byteLength(artifactBody), + url: `http://${serverAddress(server)}/artifact.dmg`, + }, + }, + }, + }, + version: 1, + })); + return; + } + if (url === "/artifact.dmg") { + response.setHeader("content-length", String(Buffer.byteLength(artifactBody))); + response.end(artifactBody); + return; + } + if (url === "/artifact.dmg.sha256") { + response.end(`${digest} artifact.dmg\n`); + return; + } + response.statusCode = 404; + response.end("not found"); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", () => resolveListen()); + }); + const address = serverAddress(server); + return { + close: async () => { + await new Promise((resolveClose, rejectClose) => { + server.close((error) => (error == null ? resolveClose() : rejectClose(error))); + }); + }, + metadataUrl: `http://${address}/metadata.json`, + }; +} + +function serverAddress(server: Server): string { + const address = server.address(); + if (address == null || typeof address === "string") throw new Error("fixture server is not listening on TCP"); + return `127.0.0.1:${address.port}`; +} + +function makeRoot(): string { + return mkdtempSync(join(tmpdir(), "od-updater-test-")); +} + +function updaterEnv(metadataUrl: string): NodeJS.ProcessEnv { + return { + [DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]: "1", + [DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.0", + [DESKTOP_UPDATE_ENV.ENABLED]: "1", + [DESKTOP_UPDATE_ENV.METADATA_URL]: metadataUrl, + [DESKTOP_UPDATE_ENV.OPEN_DRY_RUN]: "1", + [DESKTOP_UPDATE_ENV.PLATFORM]: "darwin", + }; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function waitForRequestCount(requests: readonly unknown[], count: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (requests.length >= count) return; + await new Promise((resolveWait) => setImmediate(resolveWait)); + } + throw new Error(`expected ${count} update requests, saw ${requests.length}`); +} + +function metadataResponse(version: string): Response { + return new Response(JSON.stringify({ + baseVersion: version, + channel: "stable", + platforms: { + mac: { + arch: "arm64", + enabled: true, + artifacts: { + dmg: { + name: `open-design-${version}-mac-arm64.dmg`, + sha256: "0".repeat(64), + size: 1, + url: `https://example.invalid/open-design-${version}-mac-arm64.dmg`, + }, + }, + }, + }, + releaseVersion: version, + stableVersion: version, + version: 1, + })); +} + +describe("desktop updater", () => { + it("downloads, verifies, persists, and dry-runs opening a mac package", 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 checked = await updater.checkForUpdates(); + expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + expect(checked.channel).toBe(DESKTOP_UPDATE_CHANNELS.STABLE); + expect(checked.availableVersion).toBe("1.0.1"); + expect(checked.checksum?.algorithm).toBe("sha256"); + expect(checked.downloadPath).toEqual(expect.any(String)); + expect(relative(await realpath(root), checked.downloadPath ?? "")).not.toMatch(/^\.\./); + expect(await readFile(checked.downloadPath ?? "", "utf8")).toBe("open design updater fixture"); + + const restored = await updater.status(); + expect(restored.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + expect(restored.downloadPath).toBe(checked.downloadPath); + + const installed = await updater.installUpdate(); + expect(installed.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + expect(installed.installResult?.dryRun).toBe(true); + expect(installed.installResult?.path).toBe(checked.downloadPath); + } 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" }); + 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.NOT_AVAILABLE); + expect(checked.downloadPath).toBeUndefined(); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + + it("accepts beta metadata that exposes betaVersion instead of releaseVersion", 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), + [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.DOWNLOADED); + expect(checked.channel).toBe(DESKTOP_UPDATE_CHANNELS.BETA); + expect(checked.availableVersion).toBe("1.0.1-beta.2"); + } 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" }); + try { + const updater = createDesktopUpdater({ + arch: "arm64", + downloadRoot: root, + env: { + ...updaterEnv(fixture.metadataUrl), + [DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.1-beta-nightly.1", + }, + source: SIDECAR_SOURCES.TOOLS_PACK, + }); + + const checked = await updater.checkForUpdates(); + expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + expect(checked.channel).toBe(DESKTOP_UPDATE_CHANNELS.BETA); + expect(checked.availableVersion).toBe("1.0.1-beta-nightly.2"); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + + it("re-verifies a downloaded package before opening it", 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 checked = await updater.checkForUpdates(); + expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + await writeFile(checked.downloadPath ?? "", "tampered", "utf8"); + + const installed = await updater.installUpdate(); + expect(installed.state).toBe(DESKTOP_UPDATE_STATES.ERROR); + expect(installed.error?.code).toBe("checksum-mismatch"); + expect(installed.installResult).toBeUndefined(); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + + it("serializes more than one queued update operation", async () => { + const root = makeRoot(); + const requests: Array<{ resolve: (response: Response) => void }> = []; + const fetchImpl: typeof globalThis.fetch = async () => { + const request = deferred(); + 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 }, + ); + + const first = updater.checkForUpdates({ autoDownload: false }); + const second = updater.checkForUpdates({ autoDownload: false }); + const third = updater.checkForUpdates({ autoDownload: false }); + + await waitForRequestCount(requests, 1); + expect(requests).toHaveLength(1); + + requests[0]?.resolve(metadataResponse("1.0.1")); + await expect(first).resolves.toMatchObject({ + availableVersion: "1.0.1", + state: DESKTOP_UPDATE_STATES.AVAILABLE, + }); + await waitForRequestCount(requests, 2); + await new Promise((resolveWait) => setImmediate(resolveWait)); + expect(requests).toHaveLength(2); + + requests[1]?.resolve(metadataResponse("1.0.2")); + await expect(second).resolves.toMatchObject({ + availableVersion: "1.0.2", + state: DESKTOP_UPDATE_STATES.AVAILABLE, + }); + await waitForRequestCount(requests, 3); + + requests[2]?.resolve(metadataResponse("1.0.3")); + await expect(third).resolves.toMatchObject({ + availableVersion: "1.0.3", + state: DESKTOP_UPDATE_STATES.AVAILABLE, + }); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("defaults counted beta nightly builds to the beta update channel", () => { + const root = makeRoot(); + try { + const config = resolveDesktopUpdaterConfig({ + currentVersion: "1.2.3-beta-nightly.4", + downloadRoot: root, + env: { + [DESKTOP_UPDATE_ENV.ENABLED]: "1", + }, + source: SIDECAR_SOURCES.PACKAGED, + }); + + expect(config.channel).toBe(DESKTOP_UPDATE_CHANNELS.BETA); + expect(config.metadataUrl).toContain("/beta/latest/metadata.json"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("does not offer an arm64-only mac package to x64 clients", async () => { + const root = makeRoot(); + const fixture = await createUpdaterFixture(); + try { + const updater = createDesktopUpdater({ + arch: "x64", + 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.error?.code).toBe("no-compatible-artifact"); + expect(checked.error?.message).toContain("macIntel"); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + + it("refuses aggressive cleanup in a non-owned update root", async () => { + const root = makeRoot(); + const fixture = await createUpdaterFixture(); + const alienFile = join(root, "do-not-delete.txt"); + try { + await writeFile(alienFile, "user file", "utf8"); + 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.error?.code).toBe("update-root-not-owned"); + expect(existsSync(alienFile)).toBe(true); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + + const symlinkIt = process.platform === "win32" ? it.skip : it; + symlinkIt("refuses to use a symlinked updater root", async () => { + const realRoot = makeRoot(); + const linkParent = makeRoot(); + const linkRoot = join(linkParent, "updates"); + const fixture = await createUpdaterFixture(); + try { + symlinkSync(realRoot, linkRoot, "dir"); + const updater = createDesktopUpdater({ + arch: "arm64", + downloadRoot: linkRoot, + env: updaterEnv(fixture.metadataUrl), + source: SIDECAR_SOURCES.TOOLS_PACK, + }); + + const checked = await updater.checkForUpdates(); + expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR); + expect(checked.error?.code).toBe("update-root-not-owned"); + expect(existsSync(join(realRoot, ".open-design-updater-root.json"))).toBe(false); + } finally { + await fixture.close(); + rmSync(linkParent, { force: true, recursive: true }); + rmSync(realRoot, { force: true, recursive: true }); + } + }); + + symlinkIt("refuses to use symlinked updater subdirectories", async () => { + const root = makeRoot(); + const outside = makeRoot(); + const fixture = await createUpdaterFixture(); + const outsideMarker = join(outside, "outside.txt"); + try { + await writeFile(outsideMarker, "outside", "utf8"); + const updater = createDesktopUpdater({ + arch: "arm64", + downloadRoot: root, + env: updaterEnv(fixture.metadataUrl), + source: SIDECAR_SOURCES.TOOLS_PACK, + }); + await updater.status(); + symlinkSync(outside, join(root, "artifacts"), "dir"); + + const checked = await updater.checkForUpdates(); + expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR); + expect(checked.error?.code).toBe("download-failed"); + expect(existsSync(outsideMarker)).toBe(true); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } + }); + + it("compares stable and prerelease versions", () => { + expect(compareVersions("1.0.1", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0", "1.0.0")).toBe(0); + expect(compareVersions("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1); + expect(compareVersions("1.0.0-beta-nightly.2", "1.0.0-beta-nightly.1")).toBe(1); + expect(compareVersions("1.0.0-nightly.10", "1.0.0-nightly.2")).toBe(1); + expect(compareVersions("1.0.0.nightly.2", "1.0.0.nightly.1")).toBe(1); + expect(compareVersions("1.0.0", "1.0.0-beta.9")).toBe(1); + expect(compareVersions("1.0.0-beta.1", "1.0.0")).toBe(-1); + }); +}); diff --git a/apps/packaged/src/index.ts b/apps/packaged/src/index.ts index c3c5cbc484..3943cd5829 100644 --- a/apps/packaged/src/index.ts +++ b/apps/packaged/src/index.ts @@ -117,6 +117,10 @@ async function main(): Promise { async discoverDaemonUrl() { return sidecars.daemon.url; }, + update: { + currentVersion: config.appVersion, + downloadRoot: paths.updateRoot, + }, }); } diff --git a/apps/packaged/src/launch.ts b/apps/packaged/src/launch.ts index c357e3201b..94bedc39af 100644 --- a/apps/packaged/src/launch.ts +++ b/apps/packaged/src/launch.ts @@ -104,6 +104,7 @@ export async function ensurePackagedNamespacePaths( mkdir(paths.logsRoot, { recursive: true }), mkdir(paths.desktopLogsRoot, { recursive: true }), mkdir(paths.runtimeRoot, { recursive: true }), + mkdir(paths.updateRoot, { recursive: true }), mkdir(paths.electronUserDataRoot, { recursive: true }), mkdir(paths.electronSessionDataRoot, { recursive: true }), ]); diff --git a/apps/packaged/src/paths.ts b/apps/packaged/src/paths.ts index a92088f5e5..7764685a77 100644 --- a/apps/packaged/src/paths.ts +++ b/apps/packaged/src/paths.ts @@ -17,6 +17,7 @@ export type PackagedNamespacePaths = { namespaceRoot: string; resourceRoot: string; runtimeRoot: string; + updateRoot: string; webIdentityPath: string; }; @@ -39,6 +40,7 @@ export function resolvePackagedNamespacePaths( namespaceRoot, resourceRoot: config.resourceRoot, runtimeRoot: join(namespaceRoot, "runtime"), + updateRoot: join(namespaceRoot, "updates"), webIdentityPath: join(namespaceRoot, "runtime", "web-root.json"), }; } diff --git a/apps/packaged/src/sidecars.ts b/apps/packaged/src/sidecars.ts index 87d9187347..44113b38ec 100644 --- a/apps/packaged/src/sidecars.ts +++ b/apps/packaged/src/sidecars.ts @@ -386,6 +386,7 @@ export async function startPackagedSidecars( await mkdir(paths.logsRoot, { recursive: true }); await mkdir(paths.desktopLogsRoot, { recursive: true }); await mkdir(paths.runtimeRoot, { recursive: true }); + await mkdir(paths.updateRoot, { recursive: true }); await mkdir(paths.electronUserDataRoot, { recursive: true }); await mkdir(paths.electronSessionDataRoot, { recursive: true }); diff --git a/apps/packaged/tests/desktop-project-root-gate.test.ts b/apps/packaged/tests/desktop-project-root-gate.test.ts index 6602f9b6a7..eaa2cd0bdd 100644 --- a/apps/packaged/tests/desktop-project-root-gate.test.ts +++ b/apps/packaged/tests/desktop-project-root-gate.test.ts @@ -14,7 +14,7 @@ * resolvedDir came from the trusted-picker flow. */ import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; +import { mkdir, realpath } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -70,7 +70,7 @@ describe("validateExistingDirectory", () => { it("accepts an existing absolute directory and returns the realpath", async () => { const result = await validateExistingDirectory(tempRoot); expect(result.ok).toBe(true); - if (result.ok) expect(result.resolved).toBe(tempRoot); + if (result.ok) expect(result.resolved).toBe(await realpath(tempRoot)); }); it("realpath-resolves symlinks so attackers cannot register one path and reach another", async () => { @@ -80,7 +80,7 @@ describe("validateExistingDirectory", () => { symlinkSync(realDir, linkDir, "dir"); const result = await validateExistingDirectory(linkDir); expect(result.ok).toBe(true); - if (result.ok) expect(result.resolved).toBe(realDir); + if (result.ok) expect(result.resolved).toBe(await realpath(realDir)); }); it("rejects macOS .app bundles even though they are technically directories", async () => { diff --git a/apps/packaged/tests/identity.test.ts b/apps/packaged/tests/identity.test.ts index 772d93876b..4a2d7e8706 100644 --- a/apps/packaged/tests/identity.test.ts +++ b/apps/packaged/tests/identity.test.ts @@ -37,6 +37,7 @@ function fakePaths(root: string): PackagedNamespacePaths { namespaceRoot: root, resourceRoot: join(root, "resources"), runtimeRoot: join(root, "runtime"), + updateRoot: join(root, "updates"), webIdentityPath: join(root, "runtime", "web-root.json"), }; } diff --git a/apps/packaged/tests/paths.test.ts b/apps/packaged/tests/paths.test.ts new file mode 100644 index 0000000000..2d19277fa0 --- /dev/null +++ b/apps/packaged/tests/paths.test.ts @@ -0,0 +1,31 @@ +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolvePackagedNamespacePaths } from "../src/paths.js"; +import type { PackagedConfig } from "../src/config.js"; + +describe("resolvePackagedNamespacePaths", () => { + it("models update downloads as a namespace-scoped root beside data", () => { + const config: PackagedConfig = { + appVersion: "1.2.3", + daemonCliEntry: null, + daemonSidecarEntry: null, + namespace: "release", + namespaceBaseRoot: "/tmp/open-design-packaged/namespaces", + nodeCommand: null, + resourceRoot: "/tmp/open-design-packaged/resources", + telemetryRelayUrl: null, + posthogKey: null, + posthogHost: null, + webSidecarEntry: null, + webStandaloneRoot: null, + webOutputMode: "server", + }; + + const paths = resolvePackagedNamespacePaths(config); + expect(paths.namespaceRoot).toBe(join(config.namespaceBaseRoot, "release")); + expect(paths.dataRoot).toBe(join(paths.namespaceRoot, "data")); + expect(paths.updateRoot).toBe(join(paths.namespaceRoot, "updates")); + }); +}); diff --git a/apps/packaged/tests/sidecars.test.ts b/apps/packaged/tests/sidecars.test.ts index 798e6234d8..51973cce5f 100644 --- a/apps/packaged/tests/sidecars.test.ts +++ b/apps/packaged/tests/sidecars.test.ts @@ -163,6 +163,7 @@ describe('buildPackagedDaemonSpawnEnv', () => { namespaceRoot: '/tmp/od-pkg', resourceRoot: '/tmp/od-pkg/resources', runtimeRoot: '/tmp/od-pkg/runtime', + updateRoot: '/tmp/od-pkg/updates', webIdentityPath: '/tmp/od-pkg/runtime/web-root.json', }; } diff --git a/e2e/specs/mac.spec.ts b/e2e/specs/mac.spec.ts index d03d0da89a..516c3646aa 100644 --- a/e2e/specs/mac.spec.ts +++ b/e2e/specs/mac.spec.ts @@ -1,8 +1,9 @@ // @vitest-environment node -import { execFile } from 'node:child_process'; +import { execFile, spawn, type ChildProcessByStdio } from 'node:child_process'; import { access, mkdir, stat } from 'node:fs/promises'; import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; +import type { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -81,6 +82,21 @@ type MacInspectResult = { path: string; }; status: DesktopStatus | null; + update?: { + availableVersion?: string; + channel?: string; + currentVersion?: string; + downloadPath?: string; + error?: { + code: string; + message: string; + }; + installResult?: { + dryRun?: boolean; + path: string; + }; + state: string; + }; }; type LogsResult = { @@ -88,6 +104,14 @@ type LogsResult = { namespace: string; }; +type UpdaterFixtureProcess = { + close: () => Promise; + info: { + metadataUrl: string; + version: string; + }; +}; + type HealthEvalValue = { health: { ok?: unknown; @@ -110,6 +134,8 @@ macDescribe('packaged mac runtime smoke', () => { test('installs, starts, inspects, stops, and uninstalls the built mac artifact', async () => { const report = await createPackagedSmokeReport('mac'); + const updateEnv = captureUpdateEnv(); + let updaterFixture: UpdaterFixtureProcess | null = null; let passed = false; try { const install = await runToolsPackJson('install'); @@ -120,6 +146,13 @@ macDescribe('packaged mac runtime smoke', () => { expectPathInside(install.dmgPath, join(outputNamespaceRoot, 'dmg')); expectPathInside(install.installedAppPath, join(outputNamespaceRoot, 'install', 'Applications')); + updaterFixture = await startUpdaterFixtureProcess(); + process.env.OD_UPDATE_ENABLED = '1'; + 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'; + const start = await runToolsPackJson('start'); started = true; @@ -147,6 +180,16 @@ macDescribe('packaged mac runtime smoke', () => { expect(value.health.ok).toBe(true); expect(value.health.version).toEqual(expect.any(String)); + const updateStatus = await runToolsPackJson('inspect', ['--update-action', 'check']); + 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('inspect', ['--update-action', 'install']); + expect(updateInstall.update?.state).toBe('downloaded'); + expect(updateInstall.update?.installResult?.dryRun).toBe(true); + await mkdir(dirname(screenshotPath), { recursive: true }); const screenshot = await runToolsPackJson('inspect', ['--path', screenshotPath]); expect(screenshot.screenshot?.path).toBe(screenshotPath); @@ -192,6 +235,10 @@ macDescribe('packaged mac runtime smoke', () => { }); passed = true; } finally { + restoreUpdateEnv(updateEnv); + await updaterFixture?.close().catch((error: unknown) => { + console.error('failed to close updater fixture', error); + }); if (!passed) { await printPackagedLogs().catch((error: unknown) => { console.error('failed to read packaged mac logs after failure', error); @@ -1043,6 +1090,82 @@ async function runToolsPackJson(action: string, extraArgs: string[] = []): Pr } } +const UPDATE_ENV_KEYS = [ + 'OD_UPDATE_AUTO_CHECK', + 'OD_UPDATE_ENABLED', + 'OD_UPDATE_METADATA_URL', + 'OD_UPDATE_CURRENT_VERSION', + 'OD_UPDATE_OPEN_DRY_RUN', +] as const; + +function captureUpdateEnv(): Partial> { + return Object.fromEntries( + UPDATE_ENV_KEYS + .map((key) => [key, process.env[key]] as const) + .filter((entry): entry is readonly [(typeof UPDATE_ENV_KEYS)[number], string] => entry[1] != null), + ); +} + +function restoreUpdateEnv(previous: Partial>): void { + for (const key of UPDATE_ENV_KEYS) { + if (previous[key] == null) delete process.env[key]; + else process.env[key] = previous[key]; + } +} + +async function startUpdaterFixtureProcess(): Promise { + const child = spawn(pnpmCommand, ['tools-serve', 'start', 'updater', '--json', '--channel', 'beta', '--version', '99.0.0-beta.1'], { + cwd: workspaceRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const info = await readUpdaterFixtureInfo(child); + return { + async close() { + if (child.exitCode != null) return; + child.kill('SIGTERM'); + await new Promise((resolve) => { + child.once('exit', () => resolve()); + setTimeout(resolve, 2000).unref(); + }); + }, + info, + }; +} + +async function readUpdaterFixtureInfo(child: ChildProcessByStdio): Promise { + let stdout = ''; + let stderr = ''; + return await new Promise((resolveInfo, rejectInfo) => { + const timeout = setTimeout(() => { + rejectInfo(new Error(`tools-serve updater did not report metadata in time\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }, 10_000); + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + const line = stdout.split('\n').find((entry) => entry.trim().startsWith('{')); + if (line == null) return; + clearTimeout(timeout); + try { + const parsed = JSON.parse(line) as UpdaterFixtureProcess['info']; + resolveInfo(parsed); + } catch (error) { + rejectInfo(error); + } + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + rejectInfo(new Error(`tools-serve updater exited before ready (code=${code}, signal=${signal ?? 'none'})\nstderr:\n${stderr}`)); + }); + child.once('error', (error) => { + clearTimeout(timeout); + rejectInfo(error); + }); + }); +} + type DesktopHarness = ReturnType; type DesktopSettingsSnapshot = { diff --git a/package.json b/package.json index 162515b0aa..1e17eb0575 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "tools-dev": "pnpm exec tools-dev", "tools-pack": "pnpm exec tools-pack", "tools-pr": "pnpm exec tools-pr", + "tools-serve": "pnpm exec tools-serve", "guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts", "i18n:check": "tsx ./scripts/i18n-check.ts", "i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts", @@ -27,6 +28,7 @@ "@open-design/tools-dev": "workspace:*", "@open-design/tools-pack": "workspace:*", "@open-design/tools-pr": "workspace:*", + "@open-design/tools-serve": "workspace:*", "@types/node": "^20.17.10", "tsx": "4.21.0", "typescript": "^5.6.3" diff --git a/packages/sidecar-proto/src/index.ts b/packages/sidecar-proto/src/index.ts index 421aaca1ec..07ca596a63 100644 --- a/packages/sidecar-proto/src/index.ts +++ b/packages/sidecar-proto/src/index.ts @@ -76,8 +76,46 @@ export const SIDECAR_MESSAGES = Object.freeze({ SCREENSHOT: "screenshot", SHUTDOWN: "shutdown", STATUS: "status", + UPDATE: "update", } as const); +export const DESKTOP_UPDATE_ACTIONS = Object.freeze({ + CHECK: "check", + DOWNLOAD: "download", + INSTALL: "install", + STATUS: "status", +} as const); + +export type DesktopUpdateAction = (typeof DESKTOP_UPDATE_ACTIONS)[keyof typeof DESKTOP_UPDATE_ACTIONS]; + +export const DESKTOP_UPDATE_MODES = Object.freeze({ + JS_INCREMENTAL: "js-incremental", + PACKAGE_LAUNCHER: "package-launcher", +} as const); + +export type DesktopUpdateMode = (typeof DESKTOP_UPDATE_MODES)[keyof typeof DESKTOP_UPDATE_MODES]; + +export const DESKTOP_UPDATE_CHANNELS = Object.freeze({ + BETA: "beta", + STABLE: "stable", +} as const); + +export type DesktopUpdateChannel = (typeof DESKTOP_UPDATE_CHANNELS)[keyof typeof DESKTOP_UPDATE_CHANNELS]; + +export const DESKTOP_UPDATE_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 DesktopUpdateState = (typeof DESKTOP_UPDATE_STATES)[keyof typeof DESKTOP_UPDATE_STATES]; + export const SIDECAR_ERROR_CODES = Object.freeze({ INVALID_MESSAGE: "SIDECAR_INVALID_MESSAGE", UNKNOWN_MESSAGE: "SIDECAR_UNKNOWN_MESSAGE", @@ -129,6 +167,7 @@ export type DesktopStatusSnapshot = { pid?: number | null; state: DesktopRuntimeState; title?: string | null; + update?: DesktopUpdateStatusSnapshot; updatedAt?: string; url?: string | null; windowVisible?: boolean; @@ -186,6 +225,78 @@ export type DesktopExportPdfResult = { path?: string; }; +export type DesktopUpdateCapabilitySet = { + canApplyInPlace: boolean; + canDownload: boolean; + canOpenInstaller: boolean; + requiresManualInstall: boolean; +}; + +export type DesktopUpdatePathSnapshot = { + downloadRoot?: string; + manifestPath?: string; +}; + +export type DesktopUpdateChecksumSnapshot = { + algorithm: "sha256" | "sha512"; + url?: string; + value?: string; +}; + +export type DesktopUpdateArtifactSnapshot = { + name?: string; + platformKey?: string; + size?: number; + type?: string; + url: string; +}; + +export type DesktopUpdateProgressSnapshot = { + receivedBytes: number; + totalBytes?: number; +}; + +export type DesktopUpdateErrorSnapshot = { + code: string; + details?: unknown; + message: string; +}; + +export type DesktopUpdateInstallResult = { + dryRun?: boolean; + openedAt: string; + path: string; +}; + +export type DesktopUpdateStatusSnapshot = { + arch: string; + artifact?: DesktopUpdateArtifactSnapshot; + artifactUrl?: string; + availableVersion?: string; + capabilities: DesktopUpdateCapabilitySet; + channel: DesktopUpdateChannel; + checksum?: DesktopUpdateChecksumSnapshot; + currentVersion: string; + downloadPath?: string; + enabled: boolean; + error?: DesktopUpdateErrorSnapshot; + installResult?: DesktopUpdateInstallResult; + lastCheckedAt?: string; + metadata?: Record; + mode: DesktopUpdateMode; + paths?: DesktopUpdatePathSnapshot; + platform: string; + progress?: DesktopUpdateProgressSnapshot; + state: DesktopUpdateState; + supported: boolean; +}; + +export type DesktopUpdateInput = { + action: DesktopUpdateAction; +}; + +export type DesktopUpdateResult = DesktopUpdateStatusSnapshot; + export type SidecarStatusMessage = { type: typeof SIDECAR_MESSAGES.STATUS }; export type SidecarShutdownMessage = { type: typeof SIDECAR_MESSAGES.SHUTDOWN }; export type DesktopEvalMessage = { input: DesktopEvalInput; type: typeof SIDECAR_MESSAGES.EVAL }; @@ -193,6 +304,7 @@ export type DesktopScreenshotMessage = { input: DesktopScreenshotInput; type: ty export type DesktopConsoleMessage = { type: typeof SIDECAR_MESSAGES.CONSOLE }; export type DesktopClickMessage = { input: DesktopClickInput; type: typeof SIDECAR_MESSAGES.CLICK }; export type DesktopExportPdfMessage = { input: DesktopExportPdfInput; type: typeof SIDECAR_MESSAGES.EXPORT_PDF }; +export type DesktopUpdateMessage = { input: DesktopUpdateInput; type: typeof SIDECAR_MESSAGES.UPDATE }; // Sent by the desktop main process to the daemon over its sidecar IPC at // startup, before the BrowserWindow is created. The base64 string is a @@ -228,7 +340,8 @@ export type DesktopSidecarMessage = | DesktopScreenshotMessage | DesktopConsoleMessage | DesktopClickMessage - | DesktopExportPdfMessage; + | DesktopExportPdfMessage + | DesktopUpdateMessage; export type ShutdownResult = { accepted: true; @@ -260,6 +373,10 @@ export type OpenDesignSidecarContract = { sources: typeof SIDECAR_SOURCES; stampFields: typeof SIDECAR_STAMP_FIELDS; stampFlags: typeof SIDECAR_STAMP_FLAGS; + updateActions: typeof DESKTOP_UPDATE_ACTIONS; + updateChannels: typeof DESKTOP_UPDATE_CHANNELS; + updateModes: typeof DESKTOP_UPDATE_MODES; + updateStates: typeof DESKTOP_UPDATE_STATES; }; function assertObject(value: unknown, label: string): Record { @@ -423,6 +540,19 @@ function normalizeDesktopExportPdfInput(input: unknown): DesktopExportPdfInput { }; } +function isDesktopUpdateAction(value: unknown): value is DesktopUpdateAction { + return Object.values(DESKTOP_UPDATE_ACTIONS).includes(value as DesktopUpdateAction); +} + +function normalizeDesktopUpdateInput(input: unknown): DesktopUpdateInput { + const value = assertObject(input, "desktop update input"); + assertKnownKeys(value, ["action"], "desktop update input"); + if (!isDesktopUpdateAction(value.action)) { + throw new Error(`unsupported desktop update action: ${String(value.action)}`); + } + return { action: value.action }; +} + function normalizeMessageType(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0) { throw new SidecarContractError(SIDECAR_ERROR_CODES.INVALID_MESSAGE, `${label} type must be a non-empty string`); @@ -475,6 +605,9 @@ export function normalizeDesktopSidecarMessage(input: unknown): DesktopSidecarMe case SIDECAR_MESSAGES.EXPORT_PDF: assertKnownKeys(value, ["input", "type"], "desktop sidecar message"); return { input: normalizeDesktopExportPdfInput(value.input), type }; + case SIDECAR_MESSAGES.UPDATE: + assertKnownKeys(value, ["input", "type"], "desktop sidecar message"); + return { input: normalizeDesktopUpdateInput(value.input), type }; default: throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown desktop sidecar message: ${type}`); } @@ -495,4 +628,8 @@ export const OPEN_DESIGN_SIDECAR_CONTRACT = Object.freeze({ sources: SIDECAR_SOURCES, stampFields: SIDECAR_STAMP_FIELDS, stampFlags: SIDECAR_STAMP_FLAGS, + updateActions: DESKTOP_UPDATE_ACTIONS, + updateChannels: DESKTOP_UPDATE_CHANNELS, + updateModes: DESKTOP_UPDATE_MODES, + updateStates: DESKTOP_UPDATE_STATES, } as const satisfies OpenDesignSidecarContract); diff --git a/packages/sidecar-proto/tests/index.test.ts b/packages/sidecar-proto/tests/index.test.ts index 1119bc35a4..710cec6f7f 100644 --- a/packages/sidecar-proto/tests/index.test.ts +++ b/packages/sidecar-proto/tests/index.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest"; import { APP_KEYS, + DESKTOP_UPDATE_ACTIONS, + DESKTOP_UPDATE_CHANNELS, + DESKTOP_UPDATE_MODES, + DESKTOP_UPDATE_STATES, normalizeDaemonSidecarMessage, normalizeDesktopSidecarMessage, normalizeNamespace, @@ -36,6 +40,10 @@ describe("open-design sidecar contract", () => { namespace: STAMP_NAMESPACE_FLAG, source: STAMP_SOURCE_FLAG, }); + expect(OPEN_DESIGN_SIDECAR_CONTRACT.updateActions).toBe(DESKTOP_UPDATE_ACTIONS); + expect(OPEN_DESIGN_SIDECAR_CONTRACT.updateChannels).toBe(DESKTOP_UPDATE_CHANNELS); + expect(OPEN_DESIGN_SIDECAR_CONTRACT.updateModes).toBe(DESKTOP_UPDATE_MODES); + expect(OPEN_DESIGN_SIDECAR_CONTRACT.updateStates).toBe(DESKTOP_UPDATE_STATES); }); it("accepts the explicit namespace contract", () => { @@ -161,4 +169,37 @@ describe("open-design sidecar contract", () => { }), ).toThrow(); }); + + it("validates desktop update IPC message inputs", () => { + expect( + normalizeDesktopSidecarMessage({ + input: { action: DESKTOP_UPDATE_ACTIONS.CHECK }, + type: SIDECAR_MESSAGES.UPDATE, + }), + ).toEqual({ + input: { action: "check" }, + type: "update", + }); + expect( + normalizeDesktopSidecarMessage({ + input: { action: DESKTOP_UPDATE_ACTIONS.INSTALL }, + type: SIDECAR_MESSAGES.UPDATE, + }), + ).toEqual({ + input: { action: "install" }, + type: "update", + }); + expect(() => + normalizeDesktopSidecarMessage({ + input: { action: "apply" }, + type: SIDECAR_MESSAGES.UPDATE, + }), + ).toThrow(/unsupported desktop update action/); + expect(() => + normalizeDesktopSidecarMessage({ + input: { action: "status", path: "/tmp/update.dmg" }, + type: SIDECAR_MESSAGES.UPDATE, + }), + ).toThrow(/unsupported fields/); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4b8f6d32d..d07cacd570 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@open-design/tools-pr': specifier: workspace:* version: link:tools/pr + '@open-design/tools-serve': + specifier: workspace:* + version: link:tools/serve '@types/node': specifier: ^20.17.10 version: 20.19.39 @@ -513,6 +516,28 @@ importers: specifier: 6.0.3 version: 6.0.3 + tools/serve: + dependencies: + cac: + specifier: 6.7.14 + version: 6.7.14 + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + esbuild: + specifier: 0.27.7 + version: 0.27.7 + tsx: + specifier: 4.21.0 + version: 4.21.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@24.12.2)(jsdom@29.1.1)(lightningcss@1.32.0) + packages: 7zip-bin@5.2.0: diff --git a/scripts/guard.ts b/scripts/guard.ts index fd970c8bcb..7fe30c0983 100644 --- a/scripts/guard.ts +++ b/scripts/guard.ts @@ -80,6 +80,8 @@ const residualAllowedExactPaths = new Set([ "tools/pack/esbuild.config.mjs", "tools/pr/bin/tools-pr.mjs", "tools/pr/esbuild.config.mjs", + "tools/serve/bin/tools-serve.mjs", + "tools/serve/esbuild.config.mjs", "tools/pack/resources/mac/notarize.cjs", // electron-builder hook path; CJS compatibility entry used by tools-pack desktop builds. "tools/pack/resources/web-standalone-after-pack.cjs", @@ -393,6 +395,7 @@ const toolsRootAllowlist = new Map([ ["dev", "directory"], ["pack", "directory"], ["pr", "directory"], + ["serve", "directory"], ]); async function checkToolsLayout(): Promise { @@ -406,7 +409,7 @@ async function checkToolsLayout(): Promise { const repositoryPath = `tools/${entry.name}${entry.isDirectory() ? "/" : ""}`; if (expected == null) { - violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, pack/, and pr/`); + violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, pack/, pr/, and serve/`); continue; } diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 75b06b340f..a92c945b20 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -17,6 +17,7 @@ const buildTargets = [ "tools/dev", "tools/pack", "tools/pr", + "tools/serve", ]; const jsExtensions = new Set([".js", ".cjs", ".mjs"]); diff --git a/tools/AGENTS.md b/tools/AGENTS.md index 605e119622..7504c451ec 100644 --- a/tools/AGENTS.md +++ b/tools/AGENTS.md @@ -10,6 +10,7 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie - `pnpm tools-dev inspect desktop ...` inspects the desktop runtime through sidecar IPC. - `tools/pack` provides `@open-design/tools-pack` and the `tools-pack` bin. The active slice is packaged artifact build/install/start/stop/logs/uninstall/cleanup/list/reset plus beta release artifact preparation for mac and Windows lanes, plus a Linux AppImage lane with optional containerized builds. - `tools/pr` provides `@open-design/tools-pr` and the `tools-pr` bin. It is the maintainer PR-duty control plane: a thin `gh` wrapper that encodes this repo's review-lane derivation, forbidden-surface flags, per-lane checklists, and validation-command suggestions. It must not perform side effects (approve / request changes / merge / close / push); those stay in explicit `gh` calls the maintainer runs. +- `tools/serve` provides `@open-design/tools-serve` and the `tools-serve` bin. It owns local fixture services such as `tools-serve start updater`. ## Packaging scope @@ -34,6 +35,8 @@ pnpm --filter @open-design/tools-pack typecheck pnpm --filter @open-design/tools-pack build pnpm --filter @open-design/tools-pr typecheck pnpm --filter @open-design/tools-pr build +pnpm --filter @open-design/tools-serve typecheck +pnpm --filter @open-design/tools-serve build pnpm tools-dev status --json pnpm tools-dev logs --json pnpm tools-dev check @@ -54,4 +57,5 @@ pnpm tools-pr list pnpm tools-pr list --bucket=merge-ready,approved-blocked pnpm tools-pr view pnpm tools-pr view --json +pnpm tools-serve start updater ``` diff --git a/tools/dev/src/index.ts b/tools/dev/src/index.ts index 4006c01724..17212002c1 100644 --- a/tools/dev/src/index.ts +++ b/tools/dev/src/index.ts @@ -16,6 +16,7 @@ import { type DesktopEvalResult, type DesktopScreenshotResult, type DesktopStatusSnapshot, + type DesktopUpdateResult, type WebStatusSnapshot, } from "@open-design/sidecar-proto"; import { createSidecarLaunchEnv, requestJsonIpc } from "@open-design/sidecar"; @@ -68,6 +69,7 @@ type CliOptions = ToolDevOptions & { path?: string; selector?: string; timeout?: string; + updateAction?: string; }; const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; @@ -911,6 +913,18 @@ async function inspectDesktop(config: ToolDevConfig, target: string | undefined, ); case "console": return await requestJsonIpc(config.apps.desktop.ipcPath, { type: SIDECAR_MESSAGES.CONSOLE }, { timeoutMs }); + case "update": + if ( + options.updateAction != null && + !["status", "check", "download", "install"].includes(options.updateAction) + ) { + throw new Error("--update-action must be status, check, download, or install"); + } + return await requestJsonIpc( + config.apps.desktop.ipcPath, + { input: { action: options.updateAction ?? "status" }, type: SIDECAR_MESSAGES.UPDATE }, + { timeoutMs }, + ); case "click": if (options.selector == null) throw new Error("--selector is required for desktop click"); return await requestJsonIpc( @@ -1048,6 +1062,7 @@ addSharedOptions( .option("--path ", "Output path for desktop screenshot") .option("--selector ", "CSS selector for desktop click") .option("--timeout ", "Desktop inspect timeout in seconds") + .option("--update-action ", "Desktop update action: status|check|download|install") .action(async (appName: string, target: string | undefined, options: CliOptions) => { output(await inspect(resolveToolDevConfig(options), appName, target, options), options); }); diff --git a/tools/pack/README.md b/tools/pack/README.md index e01bd33b7f..373e7d3c8a 100644 --- a/tools/pack/README.md +++ b/tools/pack/README.md @@ -50,7 +50,9 @@ namespace paths, and the packaged sidecar launcher passes daemon managed paths v own default fallback for non-packaged launches, but packaged runtime must not rely on fallback inference from Electron `userData`, app bundle names, or ports. -Runtime updater integration remains a later phase. +Packaged desktop can check the release metadata feed, download a verified mac DMG, and expose update actions through +desktop IPC. This first runtime updater phase still opens the downloaded installer for manual replacement instead of +applying an in-place update. Electron-builder resources live under `tools/pack/resources/mac/`. The current logo is staged there as the mac icon/DMG placeholder so future design-provided assets can replace the resource files without changing packaging code. diff --git a/tools/pack/src/config.ts b/tools/pack/src/config.ts index 929b516deb..fe2afc0818 100644 --- a/tools/pack/src/config.ts +++ b/tools/pack/src/config.ts @@ -37,6 +37,7 @@ export type ToolPackCliOptions = { signed?: boolean; silent?: boolean; to?: string; + updateAction?: string; }; export type ToolPackRoots = { diff --git a/tools/pack/src/index.ts b/tools/pack/src/index.ts index 2c1a575d19..adbccc89b0 100644 --- a/tools/pack/src/index.ts +++ b/tools/pack/src/index.ts @@ -67,7 +67,8 @@ function addSharedOptions(command: CacCommand) { .option("--json", "print JSON") .option("--namespace ", "runtime namespace") .option("--expr ", "desktop inspect eval expression") - .option("--path ", "desktop inspect screenshot path"); + .option("--path ", "desktop inspect screenshot path") + .option("--update-action ", "desktop update action: status|check|download|install"); } // Per-platform `--to` help text mirroring resolveToolPackBuildOutput in diff --git a/tools/pack/src/mac/lifecycle.ts b/tools/pack/src/mac/lifecycle.ts index 16c65baf79..70c138ecf7 100644 --- a/tools/pack/src/mac/lifecycle.ts +++ b/tools/pack/src/mac/lifecycle.ts @@ -12,6 +12,7 @@ import { type DesktopEvalResult, type DesktopScreenshotResult, type DesktopStatusSnapshot, + type DesktopUpdateResult, type SidecarStamp, } from "@open-design/sidecar-proto"; import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; @@ -675,13 +676,20 @@ export async function readPackedMacLogs(config: ToolPackConfig) { }; } -export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string }): Promise { +function resolveUpdateAction(value: string | undefined): "status" | "check" | "download" | "install" | null { + if (value == null) return null; + if (value === "status" || value === "check" || value === "download" || value === "install") return value; + throw new Error("--update-action must be status, check, download, or install"); +} + +export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string; updateAction?: string }): Promise { const stamp = desktopStamp(config); const status = await requestJsonIpc( stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 2000 }, ).catch(() => null); + const updateAction = resolveUpdateAction(options.updateAction); return { ...(options.expr == null ? {} : { @@ -698,6 +706,13 @@ export async function inspectPackedMacApp(config: ToolPackConfig, options: { exp { timeoutMs: 10000 }, ), }), + ...(updateAction == null ? {} : { + update: await requestJsonIpc( + stamp.ipc, + { input: { action: updateAction }, type: SIDECAR_MESSAGES.UPDATE }, + { timeoutMs: 60000 }, + ), + }), status, }; } diff --git a/tools/pack/src/mac/types.ts b/tools/pack/src/mac/types.ts index 70d4da72ed..b1ae44fbd3 100644 --- a/tools/pack/src/mac/types.ts +++ b/tools/pack/src/mac/types.ts @@ -1,4 +1,4 @@ -import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, SidecarStamp } from "@open-design/sidecar-proto"; +import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, DesktopUpdateResult, SidecarStamp } from "@open-design/sidecar-proto"; import type { CacheReport } from "../cache.js"; import type { ToolPackBuildOutput, ToolPackConfig } from "../config.js"; import type { INTERNAL_PACKAGES } from "./constants.js"; @@ -81,6 +81,7 @@ export type MacInspectResult = { eval?: DesktopEvalResult; screenshot?: DesktopScreenshotResult; status: DesktopStatusSnapshot | null; + update?: DesktopUpdateResult; }; export type DesktopRootIdentityMarker = { diff --git a/tools/pack/src/win/lifecycle.ts b/tools/pack/src/win/lifecycle.ts index 58dc21aab0..18ea8324f5 100644 --- a/tools/pack/src/win/lifecycle.ts +++ b/tools/pack/src/win/lifecycle.ts @@ -10,6 +10,7 @@ import { type DesktopEvalResult, type DesktopScreenshotResult, type DesktopStatusSnapshot, + type DesktopUpdateResult, type SidecarStamp, } from "@open-design/sidecar-proto"; import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; @@ -406,9 +407,16 @@ export async function resetPackedWinNamespaces(config: ToolPackConfig): Promise< return { namespaces, results }; } -export async function inspectPackedWinApp(config: ToolPackConfig, options: { expr?: string; path?: string }): Promise { +function resolveUpdateAction(value: string | undefined): "status" | "check" | "download" | "install" | null { + if (value == null) return null; + if (value === "status" || value === "check" || value === "download" || value === "install") return value; + throw new Error("--update-action must be status, check, download, or install"); +} + +export async function inspectPackedWinApp(config: ToolPackConfig, options: { expr?: string; path?: string; updateAction?: string }): Promise { const stamp = desktopStamp(config); const status = await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 2000 }).catch(() => null); + const updateAction = resolveUpdateAction(options.updateAction); return { ...(options.expr == null ? {} : { eval: await requestJsonIpc( @@ -424,6 +432,13 @@ export async function inspectPackedWinApp(config: ToolPackConfig, options: { exp { timeoutMs: 10000 }, ), }), + ...(updateAction == null ? {} : { + update: await requestJsonIpc( + stamp.ipc, + { input: { action: updateAction }, type: SIDECAR_MESSAGES.UPDATE }, + { timeoutMs: 60000 }, + ), + }), status, }; } diff --git a/tools/pack/src/win/types.ts b/tools/pack/src/win/types.ts index 84035c4485..5eeb0da479 100644 --- a/tools/pack/src/win/types.ts +++ b/tools/pack/src/win/types.ts @@ -1,4 +1,4 @@ -import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot } from "@open-design/sidecar-proto"; +import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, DesktopUpdateResult } from "@open-design/sidecar-proto"; import type { CacheReport } from "../cache.js"; import type { ToolPackConfig } from "../config.js"; import type { INTERNAL_PACKAGES } from "./constants.js"; @@ -314,4 +314,5 @@ export type WinInspectResult = { eval?: DesktopEvalResult; screenshot?: DesktopScreenshotResult; status: DesktopStatusSnapshot | null; + update?: DesktopUpdateResult; }; diff --git a/tools/serve/AGENTS.md b/tools/serve/AGENTS.md new file mode 100644 index 0000000000..859b28f52f --- /dev/null +++ b/tools/serve/AGENTS.md @@ -0,0 +1,14 @@ +# tools/serve + +Follow the root `AGENTS.md` and `tools/AGENTS.md` first. This tool owns small local-development service entrypoints. + +## Owns + +- `tools-serve` CLI. +- Local static updater fixtures for desktop update IPC and packaged-runtime debugging. + +## Rules + +- Keep services self-contained and local-first. +- Do not put product update runtime logic here; this tool serves deterministic fixtures only. +- New services should use explicit subcommands under `tools-serve start `. diff --git a/tools/serve/bin/tools-serve.mjs b/tools/serve/bin/tools-serve.mjs new file mode 100755 index 0000000000..f53f0350dd --- /dev/null +++ b/tools/serve/bin/tools-serve.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const entryDir = dirname(fileURLToPath(import.meta.url)); +const distEntry = resolve(entryDir, "../dist/index.mjs"); + +if (!existsSync(distEntry)) { + throw new Error(`tools-serve dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/tools-serve build" first.`); +} + +await import(pathToFileURL(distEntry).href); diff --git a/tools/serve/esbuild.config.mjs b/tools/serve/esbuild.config.mjs new file mode 100644 index 0000000000..e7db27857d --- /dev/null +++ b/tools/serve/esbuild.config.mjs @@ -0,0 +1,14 @@ +import { build } from "esbuild"; + +await build({ + banner: { + js: "#!/usr/bin/env node", + }, + bundle: true, + entryPoints: ["./src/index.ts"], + format: "esm", + outfile: "./dist/index.mjs", + packages: "external", + platform: "node", + target: "node24", +}); diff --git a/tools/serve/package.json b/tools/serve/package.json new file mode 100644 index 0000000000..48ef5b9746 --- /dev/null +++ b/tools/serve/package.json @@ -0,0 +1,28 @@ +{ + "name": "@open-design/tools-serve", + "version": "0.6.0", + "private": true, + "type": "module", + "bin": { + "tools-serve": "./bin/tools-serve.mjs" + }, + "scripts": { + "build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly", + "dev": "tsx ./src/index.ts", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit" + }, + "dependencies": { + "cac": "6.7.14" + }, + "devDependencies": { + "@types/node": "24.12.2", + "esbuild": "0.27.7", + "tsx": "4.21.0", + "typescript": "6.0.3", + "vitest": "^2.1.8" + }, + "engines": { + "node": "~24" + } +} diff --git a/tools/serve/src/index.ts b/tools/serve/src/index.ts new file mode 100644 index 0000000000..cf4870244f --- /dev/null +++ b/tools/serve/src/index.ts @@ -0,0 +1,70 @@ +import { cac } from "cac"; + +import { startUpdaterFixtureServer } from "./updater-fixture.js"; + +type CliOptions = { + channel?: "stable" | "beta"; + host?: string; + json?: boolean; + port?: string; + version?: string; +}; + +function parsePort(value: string | undefined): number { + if (value == null || value.length === 0) return 0; + const port = Number(value); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error("--port must be an integer between 0 and 65535"); + } + return port; +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +async function start(service: string, options: CliOptions): Promise { + if (service !== "updater") throw new Error(`unsupported tools-serve service: ${service}`); + const server = await startUpdaterFixtureServer({ + channel: options.channel, + host: options.host, + port: parsePort(options.port), + version: options.version, + }); + if (options.json === true) { + printJson(server.info); + } else { + process.stdout.write(`tools-serve updater: ${server.info.metadataUrl}\n`); + } + + const shutdown = () => { + void server.close().finally(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +process.on("uncaughtException", (error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); +process.on("unhandledRejection", (error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); + +const cli = cac("tools-serve"); + +cli + .command("start ", "Start a local fixture service") + .option("--channel ", "Updater channel: stable|beta", { default: "stable" }) + .option("--host ", "Host to bind", { default: "127.0.0.1" }) + .option("--json", "Print JSON") + .option("--port ", "Port to bind, 0 for dynamic", { default: "0" }) + .option("--version ", "Fixture update version", { default: "99.0.0" }) + .action((service: string, options: CliOptions) => { + void start(service, options); + }); + +cli.help(); +cli.parse(); diff --git a/tools/serve/src/updater-fixture.ts b/tools/serve/src/updater-fixture.ts new file mode 100644 index 0000000000..2e7208f208 --- /dev/null +++ b/tools/serve/src/updater-fixture.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:http"; + +export type UpdaterFixtureOptions = { + artifactBody?: Buffer | string; + channel?: "stable" | "beta"; + host?: string; + port?: number; + version?: string; +}; + +export type UpdaterFixtureInfo = { + artifactUrl: string; + channel: "stable" | "beta"; + checksumUrl: string; + metadataUrl: string; + origin: string; + sha256: string; + version: string; +}; + +export type UpdaterFixtureServer = { + close(): Promise; + info: UpdaterFixtureInfo; +}; + +function listen(server: Server, port: number, host: string): Promise { + return new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(port, host, () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolveClose, rejectClose) => { + server.close((error) => (error == null ? resolveClose() : rejectClose(error))); + }); +} + +function serverOrigin(server: Server): string { + const address = server.address(); + if (address == null || typeof address === "string") throw new Error("updater fixture did not listen on TCP"); + return `http://127.0.0.1:${address.port}`; +} + +function prereleaseCounterParts(version: string): { baseVersion: string; number: number } | null { + const prerelease = /^(\d+\.\d+\.\d+)-.+\.(\d+)$/.exec(version); + if (prerelease?.[1] != null && prerelease[2] != null) { + return { baseVersion: prerelease[1], number: Number(prerelease[2]) }; + } + const nightly = /^(\d+\.\d+\.\d+)\.nightly\.(\d+)$/i.exec(version); + if (nightly?.[1] != null && nightly[2] != null) { + return { baseVersion: nightly[1], number: Number(nightly[2]) }; + } + return null; +} + +function channelMetadata(channel: "stable" | "beta", version: string): Record { + if (channel === "stable") { + return { + baseVersion: version, + releaseVersion: version, + stableVersion: version, + }; + } + + const countedVersion = prereleaseCounterParts(version); + if (countedVersion == null) { + throw new Error(`beta updater fixture version must match x.y.z-