diff --git a/.github/workflows/release-beta-s.yml b/.github/workflows/release-beta-s.yml index 3dc9f0f3f7..26b84f62f5 100644 --- a/.github/workflows/release-beta-s.yml +++ b/.github/workflows/release-beta-s.yml @@ -979,6 +979,10 @@ jobs: RELEASE_LATEST_CAS_REQUIRED: "true" RELEASE_ASSET_SUFFIX: auto RELEASE_CHANNEL: betas + RELEASE_LAUNCHER_VERSION_MIN_BETAS: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_BETAS }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_BETAS: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_BETAS }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 31b15f7c20..583b99d6bb 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -1437,6 +1437,10 @@ jobs: MAC_X64_RESULT: ${{ needs.build_mac_x64.result }} RELEASE_ASSET_SUFFIX: ${{ needs.metadata.outputs.asset_version_suffix }} RELEASE_CHANNEL: beta + RELEASE_LAUNCHER_VERSION_MIN_BETA: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_BETA }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_BETA: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_BETA }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json @@ -1472,6 +1476,10 @@ jobs: MAC_ARM64_RESULT: ${{ needs.build_mac_arm64.result }} MAC_X64_RESULT: ${{ needs.build_mac_x64.result }} RELEASE_CHANNEL: beta + RELEASE_LAUNCHER_VERSION_MIN_BETA: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_BETA }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_BETA: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_BETA }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} diff --git a/.github/workflows/release-prerelease.yml b/.github/workflows/release-prerelease.yml index d2f6dcefa6..fbfd89d5a7 100644 --- a/.github/workflows/release-prerelease.yml +++ b/.github/workflows/release-prerelease.yml @@ -1287,6 +1287,10 @@ jobs: MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} RELEASE_ASSET_SUFFIX: "" RELEASE_CHANNEL: prerelease + RELEASE_LAUNCHER_VERSION_MIN_PRERELEASE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_PRERELEASE }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_PRERELEASE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_PRERELEASE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json @@ -1320,6 +1324,10 @@ jobs: MAC_ARM64_RESULT: ${{ needs.build_mac.result }} MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} RELEASE_CHANNEL: prerelease + RELEASE_LAUNCHER_VERSION_MIN_PRERELEASE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_PRERELEASE }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_PRERELEASE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_PRERELEASE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index 2867f3b3b1..fbb0c6f68d 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -1284,6 +1284,10 @@ jobs: MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} RELEASE_ASSET_SUFFIX: "" RELEASE_CHANNEL: preview + RELEASE_LAUNCHER_VERSION_MIN_PREVIEW: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_PREVIEW }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_PREVIEW: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_PREVIEW }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json @@ -1319,6 +1323,10 @@ jobs: MAC_ARM64_RESULT: ${{ needs.build_mac.result }} MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} RELEASE_CHANNEL: preview + RELEASE_LAUNCHER_VERSION_MIN_PREVIEW: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_PREVIEW }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_PREVIEW: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_PREVIEW }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 27a56e1230..c79cbc31a6 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -1324,6 +1324,8 @@ jobs: RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }} RELEASE_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }} RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata @@ -1367,6 +1369,8 @@ jobs: MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} RELEASE_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }} RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_LAUNCHER_VERSION_MIN_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE }} + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: ${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE }} RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_METADATA_PATH: ${{ needs.metadata.outputs.publish_side_effects_enabled == 'true' && '' || format('{0}/release-metadata/metadata.json', runner.temp) }} RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json diff --git a/apps/desktop/src/main/preload.cts b/apps/desktop/src/main/preload.cts index 37e3b73796..27836d956f 100644 --- a/apps/desktop/src/main/preload.cts +++ b/apps/desktop/src/main/preload.cts @@ -244,7 +244,7 @@ const capture = { }; function invokeUpdater( - action: 'check' | 'download' | 'install' | 'status', + action: 'check' | 'clear-cache' | 'download' | 'install' | 'status', options?: OpenDesignHostUpdaterActionOptions, ): Promise { return ipcRenderer.invoke(`od:update:${action}`, options ?? null); @@ -253,6 +253,8 @@ function invokeUpdater( const updater = { check: (options?: OpenDesignHostUpdaterActionOptions): Promise => invokeUpdater('check', options), + 'clear-cache': (options?: OpenDesignHostUpdaterActionOptions): Promise => + invokeUpdater('clear-cache', options), download: (options?: OpenDesignHostUpdaterActionOptions): Promise => invokeUpdater('download', options), install: (options?: OpenDesignHostUpdaterActionOptions): Promise => diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index 3536b007c6..f239f76ae0 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -2371,6 +2371,12 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom sendUpdaterStatus(status); return status; }); + ipcMain.handle("od:update:clear-cache", async (event) => { + requireMainWindowSender(event); + const status = await (options.updater?.clearCache() ?? unavailableUpdaterStatus()); + sendUpdaterStatus(status); + return status; + }); ipcMain.handle("od:update:download", async (event) => { requireMainWindowSender(event); const status = await (options.updater?.downloadUpdate() ?? unavailableUpdaterStatus()); diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 83c2b70146..58f47d8dc7 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -28,6 +28,7 @@ import { import { LAUNCHER_SCHEMA_VERSION, buildLauncherAfterQuitArgs, + buildLauncherDelegatedArgs, compareLauncherVersions, resolveLauncherPaths, resolveLauncherVersionPaths, @@ -54,6 +55,7 @@ import { type DesktopUpdateReleaseLifecycleState, type DesktopUpdateMode, type DesktopUpdateProgressSnapshot, + type DesktopUpdateReinstallSnapshot, type DesktopUpdateStatusSnapshot, type DesktopUpdateState, type SidecarSource, @@ -80,6 +82,7 @@ export const DESKTOP_UPDATE_ENV = Object.freeze({ CURRENT_VERSION: "OD_UPDATE_CURRENT_VERSION", DOWNLOAD_ROOT: "OD_UPDATE_DOWNLOAD_ROOT", ENABLED: "OD_UPDATE_ENABLED", + INSTALLED_VERSION: "OD_UPDATE_INSTALLED_VERSION", METADATA_URL: "OD_UPDATE_METADATA_URL", MODE: "OD_UPDATE_MODE", OPEN_DRY_RUN: "OD_UPDATE_OPEN_DRY_RUN", @@ -145,6 +148,7 @@ export type DesktopUpdaterConfig = { currentVersion: string; downloadRoot: string; enabled: boolean; + installedVersionOverride?: string; installerObservationRoot?: string; launcherLaunchPath?: string; launcherRoot?: string; @@ -182,7 +186,7 @@ export type LauncherPayloadExtractInput = { type DesktopUpdaterLogger = Pick & Partial>; type DetachedProcess = Pick, "once" | "unref">; -type LauncherPayloadCleanupTrigger = "activate" | "prepare-existing" | "prepare-promoted"; +type LauncherPayloadCleanupTrigger = "activate" | "manual-clear" | "prepare-existing" | "prepare-promoted"; type LauncherPayloadCleanupFailure = { error: NonNullable; version: string; @@ -206,6 +210,12 @@ export type DeferredAppLaunchInput = { appPid: number; /** Stable namespace root inherited by the next payload process. */ cwd: string; + /** + * Pointer the activation pre-armed attempt.json for; passed to the spawned + * payload as `--od-launcher-delegated-*` so it recognizes that attempt as + * its own launch in progress rather than a previous failure. + */ + delegated?: { generation: number; version: string }; launchPath: string; root: string; timeoutMs: number; @@ -293,6 +303,7 @@ type DesktopUpdaterStoreLayout = { type ReleaseCleanupReason = | "cleanup-failed" | "current-version-or-newer" + | "manual-clear" | "metadata-invalid" | "metadata-missing" | "older-than-current-version"; @@ -332,6 +343,7 @@ type LauncherCleanupLifecycleSummary = { export type DesktopUpdater = { checkForUpdates(options?: ActionOptions): Promise; + clearCache(): Promise; config: DesktopUpdaterConfig; downloadUpdate(): Promise; handle(action: DesktopUpdateAction): Promise; @@ -433,6 +445,7 @@ export function resolveDesktopUpdaterConfig(input: DesktopUpdaterConfigInput): D input.appVersion ?? "0.0.0"; const channel = normalizeChannel(env[DESKTOP_UPDATE_ENV.CHANNEL], defaultChannelForVersion(currentVersion)); + const installedVersionOverride = normalizeOptionalNonEmpty(env[DESKTOP_UPDATE_ENV.INSTALLED_VERSION]); const installerObservationRoot = normalizeOptionalRoot(input.installerObservationRoot, "installer observation root"); const launcherLaunchPath = normalizeOptionalNonEmpty(input.launcherLaunchPath); const launcherRoot = normalizeOptionalRoot(input.launcherRoot, "launcher root"); @@ -469,6 +482,7 @@ export function resolveDesktopUpdaterConfig(input: DesktopUpdaterConfigInput): D currentVersion, downloadRoot, enabled, + ...(installedVersionOverride == null ? {} : { installedVersionOverride }), ...(installerObservationRoot == null ? {} : { installerObservationRoot }), ...(launcherLaunchPath == null ? {} : { launcherLaunchPath }), ...(launcherRoot == null ? {} : { launcherRoot }), @@ -826,6 +840,41 @@ async function ensureOwnedUpdateRoot( } } +/** + * Manual-clear degradation for a corrupt store: rebuild an update root the + * updater can PROVE it owns. Proof is the current-generation ownership + * sentinel — everything else inside an owned root is updater cache by + * definition and safe to purge. Roots without a sentinel (unowned), with a + * foreign-generation marker (another updater's store), or failing IO are + * never touched; the caller surfaces the original store error instead. + */ +async function rebuildOwnedUpdateRootForManualClear( + config: DesktopUpdaterConfig, + logger: DesktopUpdaterLogger, +): Promise { + try { + const root = normalizeDownloadRoot(config.downloadRoot); + const rootEntry = await lstat(root); + if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) return false; + const realRoot = await realpath(root); + const layout = resolveDesktopUpdaterStoreLayout(realRoot); + const sentinel = await readJson<{ version?: number }>(layout.ownershipSentinelPath); + if (sentinel == null || sentinel.version !== UPDATE_ROOT_VERSION) return false; + for (const entry of await readdir(realRoot)) { + if (entry === OWNERSHIP_SENTINEL) continue; + await rm(join(realRoot, entry), { force: true, recursive: true }); + } + await writeJson(layout.metadataPath, { version: STORE_METADATA_VERSION }); + logger.warn("[open-design updater] rebuilt corrupt owned update store for manual clear", { root: realRoot }); + return true; + } catch (error) { + logger.warn("[open-design updater] failed to rebuild corrupt update store for manual clear", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + function defaultChannelForVersion(version: string): DesktopUpdateChannel { const channel = releaseChannelFromVersion(version); return channel ?? DESKTOP_UPDATE_CHANNELS.STABLE; @@ -1036,39 +1085,97 @@ function selectUpdateCandidateWithFallback( return selectUpdateCandidate(metadata, config); } -function controlLauncherVersionMin(metadata: Record): string | null { +function controlLauncherVersion(metadata: Record): Record | null { const control = objectField(metadata, "control"); const launcher = control == null ? null : objectField(control, "launcher"); - const version = launcher == null ? null : objectField(launcher, "version"); + return launcher == null ? null : objectField(launcher, "version"); +} + +function controlLauncherVersionMin(metadata: Record): string | null { + const version = controlLauncherVersion(metadata); return version == null ? null : stringField(version, "min"); } +function controlLauncherVersionUrl(metadata: Record): string | null { + const version = controlLauncherVersion(metadata); + return version == null ? null : stringField(version, "url"); +} + +/** + * Resolve the version of the PHYSICALLY INSTALLED outer package. This is + * distinct from `config.currentVersion`: after a payload update the running + * version is the payload's, while the installed outer bundle on disk stays at + * its install-time version and is the thing an installer reinstall replaces. + * The outer bundle's own `open-design-config.json` is the only fleet-wide + * source (every packaged generation ships it), anchored by the launcher + * launch path from `install.json`. Returns null when unreadable. + */ +export async function resolveInstalledOuterVersion(config: DesktopUpdaterConfig): Promise { + if (config.installedVersionOverride != null) return config.installedVersionOverride; + if (config.launcherLaunchPath == null) return null; + const outerConfigPath = + config.platform === "darwin" + ? join(config.launcherLaunchPath, "Contents", "Resources", "open-design-config.json") + : join(dirname(config.launcherLaunchPath), "resources", "open-design-config.json"); + try { + const raw: unknown = JSON.parse(await readFile(outerConfigPath, "utf8")); + if (!isRecord(raw)) return null; + return stringField(raw, "appVersion"); + } catch { + return null; + } +} + /** * Installed-base escape hatch: decide whether the remote release is beyond what - * this build can adopt as an in-place payload update, forcing a full installer - * instead. Two orthogonal guardrails, either of which trips → installer: + * this install can adopt as an in-place payload update, forcing a full + * installer instead. Two orthogonal guardrails, either of which trips → + * installer: * * - `launcher.schema` (ABI axis): the release declares a launcher-contract schema * number this build cannot interpret (`feed.launcher.schema > * LAUNCHER_SCHEMA_VERSION`). This is the reseed boundary — a pure int compare. * - `control.launcher.version.min` (recency axis): the release requires a - * launcher/build version newer than this one (`min > currentVersion`). + * physically installed outer package at least this new (`min > + * installedOuterVersion`). Payload updates never touch the outer bundle, so + * the comparison basis is the installed outer version, NOT the running + * version — a broken outer generation must reach the installer path even + * when its payload is current. When min is set but the outer version cannot + * be read, the gate trips conservatively: local state that cannot be + * identified is itself a reinstall signal. * - * Both are feed declarations read here; a future launcher enforces the same schema - * floor locally against on-disk manifests. Missing/malformed fields are ignored - * (fail-open) so older feeds keep updating seamlessly. + * Missing/malformed metadata fields are ignored (fail-open) so older feeds keep + * updating seamlessly. Returns the reinstall requirement for the status + * snapshot, or null when an in-place payload update is acceptable. */ -export function remoteRequiresReinstall(metadata: Record, config: DesktopUpdaterConfig): boolean { +export function remoteRequiresReinstall( + metadata: Record, + config: DesktopUpdaterConfig, + installedOuterVersion: string | null, +): DesktopUpdateReinstallSnapshot | null { + const minVersion = controlLauncherVersionMin(metadata); + const url = controlLauncherVersionUrl(metadata); + const shared = { + ...(minVersion == null ? {} : { minVersion }), + ...(url == null ? {} : { url }), + }; const launcher = objectField(metadata, "launcher"); const remoteLauncherSchema = launcher == null ? undefined : numberField(launcher, "schema"); if (remoteLauncherSchema != null && remoteLauncherSchema > LAUNCHER_SCHEMA_VERSION) { - return true; + return { + ...(installedOuterVersion == null ? {} : { installedVersion: installedOuterVersion }), + reason: "launcher-schema", + ...shared, + }; } - const minVersion = controlLauncherVersionMin(metadata); - if (minVersion != null && compareVersions(minVersion, config.currentVersion) > 0) { - return true; + if (minVersion == null) return null; + if (installedOuterVersion == null) { + return { reason: "outer-version-unreadable", ...shared }; } - return false; + if (compareVersions(minVersion, installedOuterVersion) > 0) { + return { installedVersion: installedOuterVersion, reason: "outer-below-min", ...shared }; + } + return null; } async function fetchJson(fetchImpl: typeof globalThis.fetch, url: string): Promise> { @@ -1452,6 +1559,19 @@ async function activatePreparedLauncherPayloadRelease(input: { updatedAt: input.now().toISOString(), }; await writeJson(input.config.launcherRuntimePath, nextRuntime); + // Pre-arm the launch attempt for the activated pointer: the relaunched + // payload carries the matching delegated pointer and treats this attempt as + // its own launch in progress, while a payload that dies before reaching its + // own bookkeeping leaves the attempt behind as rollback evidence for the + // next cold start. + await writeJson(launcherPaths.attemptsPath, { + channel: input.config.channel, + generation: nextActive.generation, + namespace: input.config.namespace, + schemaVersion: LAUNCHER_SCHEMA_VERSION, + startedAt: input.now().toISOString(), + version: nextActive.version, + } satisfies LauncherAttemptDescriptor); if (retryFailedGeneration) { await rm(launcherPaths.handoffPath, { force: true }); } @@ -1869,7 +1989,10 @@ async function launchPayloadAppAfterQuit( try { const child = deps.spawnDetached( input.launchPath, - buildLauncherAfterQuitArgs({ targetPid: input.appPid, timeoutMs: input.timeoutMs }), + [ + ...buildLauncherAfterQuitArgs({ targetPid: input.appPid, timeoutMs: input.timeoutMs }), + ...(input.delegated == null ? [] : buildLauncherDelegatedArgs(input.delegated)), + ], { cwd: input.cwd, detached: true, stdio: "ignore", windowsHide: true }, ); await new Promise((resolveSpawn, rejectSpawn) => { @@ -1930,6 +2053,7 @@ function isReleaseLifecycleState(value: unknown): value is DesktopUpdateReleaseL function isReleaseCleanupReason(value: unknown): value is ReleaseCleanupReason { return value === "cleanup-failed" || value === "current-version-or-newer" || + value === "manual-clear" || value === "metadata-invalid" || value === "metadata-missing" || value === "older-than-current-version"; @@ -1961,7 +2085,7 @@ function isReleaseCleanupDescriptor(value: unknown): value is ReleaseCleanupDesc if (!isRecord(value)) return false; if (value.version !== RELEASE_CLEANUP_DESCRIPTOR_VERSION) return false; if (typeof value.platform !== "string") return false; - if (value.trigger !== "cold-start" && value.trigger !== "next-version-ready") return false; + if (value.trigger !== "cold-start" && value.trigger !== "manual" && value.trigger !== "next-version-ready") return false; if (typeof value.updatedAt !== "string") return false; if (value.currentVersion != null && typeof value.currentVersion !== "string") return false; if (value.readyVersion != null && typeof value.readyVersion !== "string") return false; @@ -2021,27 +2145,64 @@ async function withUpdaterLifecycleLock( layout: DesktopUpdaterStoreLayout, logger: DesktopUpdaterLogger, task: () => Promise, + options: { reclaimStale?: boolean } = {}, ): Promise { await mkdir(layout.stateRoot, { recursive: true }); + const acquire = async (): Promise => { + try { + await mkdir(layout.lockRoot); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + return false; + } + }; + let acquired = await acquire(); + if (!acquired && options.reclaimStale === true) { + const owner = await readJson(join(layout.lockRoot, LOCK_OWNER_FILE)); + const ownerPid = isRecord(owner) && owner.owner === "open-design-updater-lifecycle" + && owner.version === RELEASE_CLEANUP_DESCRIPTOR_VERSION + && typeof owner.pid === "number" && Number.isSafeInteger(owner.pid) && owner.pid > 0 + ? owner.pid + : null; + let ownerIsDead = false; + if (ownerPid != null) { + try { + process.kill(ownerPid, 0); + } catch (error) { + ownerIsDead = (error as NodeJS.ErrnoException).code === "ESRCH"; + } + } + if (ownerIsDead) { + const staleLockRoot = `${layout.lockRoot}.stale-${process.pid}-${Date.now()}`; + try { + await rename(layout.lockRoot, staleLockRoot); + await rm(staleLockRoot, { force: true, recursive: true }); + acquired = await acquire(); + if (acquired) { + logger.warn("[open-design updater] reclaimed stale updater lifecycle lock", { + lockRoot: layout.lockRoot, + ownerPid, + }); + } + } catch (error) { + logger.warn("[open-design updater] failed to reclaim stale updater lifecycle lock", error); + } + } + } + if (!acquired) { + logger.warn("[open-design updater] skipped release lifecycle because updater lifecycle lock is held", { + lockRoot: layout.lockRoot, + }); + return null; + } try { - await mkdir(layout.lockRoot); await writeJson(join(layout.lockRoot, LOCK_OWNER_FILE), { createdAt: new Date().toISOString(), owner: "open-design-updater-lifecycle", pid: process.pid, version: RELEASE_CLEANUP_DESCRIPTOR_VERSION, }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "EEXIST") { - logger.warn("[open-design updater] skipped release lifecycle because updater lifecycle lock is held", { - lockRoot: layout.lockRoot, - }); - return null; - } - throw error; - } - try { return await task(); } finally { await rm(layout.lockRoot, { force: true, recursive: true }).catch((error: unknown) => { @@ -2063,12 +2224,15 @@ function mergeExistingReleaseCleanupEntry( async function scanReleaseCleanupEntries(input: { config: DesktopUpdaterConfig; + // Manual clear resets the downloaded-update state entirely, so every scanned + // release is deprecated regardless of its version relative to the running one. + deprecateAll?: boolean; descriptor: ReleaseCleanupDescriptor | null; layout: DesktopUpdaterStoreLayout; nowIso: string; readyVersion?: string; }): Promise { - const { config, descriptor, layout, nowIso, readyVersion } = input; + const { config, deprecateAll, descriptor, layout, nowIso, readyVersion } = input; const existing = new Map((descriptor?.releases ?? []).map((entry) => [entry.key, entry] as const)); const entries = await readdir(layout.releasesRoot, { withFileTypes: true }).catch(() => []); const nextEntries: ReleaseCleanupEntry[] = []; @@ -2150,14 +2314,18 @@ async function scanReleaseCleanupEntries(input: { continue; } - const deprecated = compareVersions(version, config.currentVersion) < 0; + const deprecated = deprecateAll === true || compareVersions(version, config.currentVersion) < 0; const next: ReleaseCleanupEntry = { currentVersion: config.currentVersion, key: entry.name, metadataPath: relativeStorePath(layout, metadataPath), path: relativeStorePath(layout, releaseDir), ...(readyVersion == null ? {} : { readyVersion }), - reason: deprecated ? "older-than-current-version" : "current-version-or-newer", + reason: deprecateAll === true + ? "manual-clear" + : deprecated + ? "older-than-current-version" + : "current-version-or-newer", state: deprecated ? "deprecated" : "retained", updatedAt: nowIso, version, @@ -2247,6 +2415,7 @@ async function runUpdateReleaseLifecycle(input: { layout: DesktopUpdaterStoreLayout; logger: DesktopUpdaterLogger; now: () => Date; + reclaimStaleLock?: boolean; readyVersion?: string; trigger: DesktopUpdateCacheLifecycleTrigger; }): Promise { @@ -2255,13 +2424,14 @@ async function runUpdateReleaseLifecycle(input: { const startedAt = now().toISOString(); const current = await readReleaseCleanupDescriptor(layout); let next: ReleaseCleanupDescriptor; - if (trigger === "next-version-ready") { + if (trigger === "next-version-ready" || trigger === "manual") { next = { currentVersion: config.currentVersion, platform: config.platform, ...(readyVersion == null ? {} : { readyVersion }), releases: await scanReleaseCleanupEntries({ config, + deprecateAll: trigger === "manual", descriptor: current, layout, nowIso: startedAt, @@ -2297,7 +2467,7 @@ async function runUpdateReleaseLifecycle(input: { }); await writeJson(layout.cleanupPath, cleaned); return summarizeReleaseCleanupDescriptor(cleaned, config.platform); - }); + }, { reclaimStale: input.reclaimStaleLock }); } function launcherCleanupError(code: string, message: string): NonNullable { @@ -2321,6 +2491,95 @@ function summarizeLauncherCleanupDescriptor(descriptor: LauncherCleanupDescripto return summary; } +/** + * Manual disaster-recovery clear of launcher-side state: removes a stale + * attempt.json, removes a non-terminal desktop-handoff journal, and deletes + * any payload version directory not retained by runtime pointers or explicit + * retained cleanup entries. The running desktop's own version is always + * retained through runtime.active. A confirmed handoff journal is a successful + * terminal state consulted by historical-outer cold starts and must survive. + * When the runtime descriptor is unreadable the retained set is unknown, so + * version cleanup is skipped entirely rather than risking the active payload. + */ +async function clearLauncherStateForManualClear(input: { + config: DesktopUpdaterConfig; + logger: DesktopUpdaterLogger; + now: () => Date; + removeLauncherPayloadRoot: (path: string) => Promise; +}): Promise { + const { config, logger, now, removeLauncherPayloadRoot } = input; + if (config.launcherRoot == null || config.launcherRuntimePath == null || config.namespace == null) return; + const launcherPaths = resolveLauncherPaths({ + channel: config.channel, + namespace: config.namespace, + root: config.launcherRoot, + }); + + // The app is running its active payload right now, so an unconfirmed attempt + // is leftover state from an interrupted transition. Removing it means the + // next cold start retries the active pointer instead of rolling back — the + // deliberate trade of a manual reset. + await rm(launcherPaths.attemptsPath, { force: true }).catch((error: unknown) => { + logger.warn("[open-design updater] failed to clear stale launcher attempt", { + error: error instanceof Error ? error.message : String(error), + path: launcherPaths.attemptsPath, + }); + }); + + const rawHandoff = await readFile(launcherPaths.handoffPath, "utf8").catch(() => null); + if (rawHandoff != null) { + let confirmed = false; + try { + const parsed: unknown = JSON.parse(rawHandoff); + confirmed = isRecord(parsed) && parsed.state === "confirmed"; + } catch { + confirmed = false; + } + if (!confirmed) { + await rm(launcherPaths.handoffPath, { force: true }).catch((error: unknown) => { + logger.warn("[open-design updater] failed to clear stale desktop handoff journal", { + error: error instanceof Error ? error.message : String(error), + path: launcherPaths.handoffPath, + }); + }); + } + } + + let runtime: LauncherRuntimeDescriptor; + try { + runtime = validateLauncherRuntimeDescriptor( + await readJsonStrict(config.launcherRuntimePath), + { channel: config.channel, namespace: config.namespace }, + ); + } catch (error) { + logger.warn("[open-design updater] skipped manual launcher version cleanup because runtime state is unreadable", { + error: error instanceof Error ? error.message : String(error), + runtimePath: config.launcherRuntimePath, + }); + return; + } + const keepVersions = new Set([ + ...(runtime.active == null ? [] : [runtime.active.version]), + ...(runtime.lastSuccessful == null ? [] : [runtime.lastSuccessful.version]), + ]); + const versionPaths = resolveLauncherVersionPaths({ + channel: config.channel, + namespace: config.namespace, + root: config.launcherRoot, + version: runtime.active?.version ?? config.currentVersion, + }); + await cleanupLauncherPayloadRoots({ + config, + currentRuntime: runtime, + keepVersions, + logger, + now, + removeLauncherPayloadRoot, + trigger: "manual-clear", + versionPaths, + }); +} + async function runLauncherCleanupLifecycle(input: { config: DesktopUpdaterConfig; logger: DesktopUpdaterLogger; @@ -2525,10 +2784,14 @@ async function loadActiveRelease( metadata: UpdateStoreMetadata, config: DesktopUpdaterConfig, logger: DesktopUpdaterLogger, + allowCurrentVersion = false, ): Promise<{ active: LoadedRelease | null; ok: true } | { error: DesktopUpdateErrorSnapshot; ok: false }> { const active = metadata.active; if (active == null) return { ok: true, active: null }; - if (compareVersions(active.version, config.currentVersion) <= 0) return { ok: true, active: null }; + const currentVersionComparison = compareVersions(active.version, config.currentVersion); + if (currentVersionComparison < 0 || (currentVersionComparison === 0 && !allowCurrentVersion)) { + return { ok: true, active: null }; + } const artifactPath = resolve(root.realRoot, active.artifactPath); if (!containsPath(root.realRoot, artifactPath)) { const error = storeShapeError(root.realRoot, "active release artifact path escaped update root", { artifactPath }); @@ -2640,6 +2903,7 @@ export function createDesktopUpdater( let installFrozen = false; let lifecycleSummary: DesktopUpdateCacheLifecycleSummary | undefined; let progress: DesktopUpdateProgressSnapshot | undefined; + let reinstallRequirement: DesktopUpdateReinstallSnapshot | undefined; let state: DesktopUpdateState = DESKTOP_UPDATE_STATES.IDLE; let error: DesktopUpdateErrorSnapshot | undefined; let operation: Promise = Promise.resolve(); @@ -2727,6 +2991,7 @@ export function createDesktopUpdater( paths: { downloadRoot: config.downloadRoot, manifestPath: join(config.downloadRoot, STORE_METADATA_FILE) }, platform: config.platform, ...(progress == null ? {} : { progress }), + ...(reinstallRequirement == null ? {} : { reinstall: reinstallRequirement }), state, supported: statusSupported, }; @@ -2786,9 +3051,34 @@ export function createDesktopUpdater( const opened = await openStore(); if (!opened.ok) return opened.status; const restoredMetadata = await clearInterruptedIncomingDownload(opened.root, opened.metadata, logger); - const loadedActive = await loadActiveRelease(opened.root, restoredMetadata, config, logger); + const storedActive = restoredMetadata.active; + const launcherPayloadContextValid = storedActive != null + && storedActive.artifact.type === "installer" + && compareVersions(storedActive.version, config.currentVersion) === 0 + && await hasValidLauncherPayloadContext(config); + const restoredReinstallRequirement = launcherPayloadContextValid + ? remoteRequiresReinstall( + storedActive.metadata, + config, + await resolveInstalledOuterVersion(config), + ) ?? undefined + : undefined; + const restoreSameVersionReinstall = + restoredReinstallRequirement != null + && restoredReinstallRequirement.reason !== "launcher-schema" + && restoredReinstallRequirement.minVersion != null + && storedActive != null + && compareVersions(restoredReinstallRequirement.minVersion, storedActive.version) <= 0; + const loadedActive = await loadActiveRelease( + opened.root, + restoredMetadata, + config, + logger, + restoreSameVersionReinstall, + ); if (!loadedActive.ok) return setState(DESKTOP_UPDATE_STATES.ERROR, loadedActive.error); activeRelease = loadedActive.active; + reinstallRequirement = activeRelease == null ? undefined : restoredReinstallRequirement; // If the app now runs at or beyond the stored active release, the // external installer succeeded and its one-shot UI state is stale. const clearedAppliedRelease = @@ -2798,18 +3088,34 @@ export function createDesktopUpdater( restoredMetadata.installFrozen === true || restoredMetadata.installResult != null ); - if (clearedAppliedRelease) { + // A payload install records the promised relaunch version in + // installResult.activeVersion. If this process is running an OLDER + // version, that relaunch never stuck — the payload crashed and the + // launcher rolled back. The freeze and stale install result must not + // survive, or every future check on the rolled-back install would be a + // frozen no-op; the downloaded release itself stays verified and + // user-actionable. + const staleRelaunchFreeze = + !clearedAppliedRelease && + restoredMetadata.installResult?.activeVersion != null && + compareVersions(restoredMetadata.installResult.activeVersion, config.currentVersion) > 0; + if (clearedAppliedRelease || staleRelaunchFreeze) { await writeStoreMetadata(opened.root, { ...restoredMetadata, - active: undefined, + ...(clearedAppliedRelease ? { active: undefined } : {}), incoming: undefined, installFrozen: undefined, installResult: undefined, version: STORE_METADATA_VERSION, }); + if (staleRelaunchFreeze) { + logUpdateEvent("restore-cleared-stale-relaunch-freeze", { + promisedVersion: restoredMetadata.installResult?.activeVersion, + }); + } } - installFrozen = clearedAppliedRelease ? false : restoredMetadata.installFrozen === true; - installResult = clearedAppliedRelease ? undefined : restoredMetadata.installResult; + installFrozen = clearedAppliedRelease || staleRelaunchFreeze ? false : restoredMetadata.installFrozen === true; + installResult = clearedAppliedRelease || staleRelaunchFreeze ? undefined : restoredMetadata.installResult; lastCheckedAt = restoredMetadata.lastCheckedAt; metadata = activeRelease?.ref.metadata ?? null; candidate = null; @@ -2915,20 +3221,36 @@ export function createDesktopUpdater( })); if (root != null) scheduleBackCleanup(root.realRoot, logger); const launcherPayloadContextValid = await hasValidLauncherPayloadContext(config); - const reseedRequired = launcherPayloadContextValid && remoteRequiresReinstall(body, config); - if (reseedRequired) { + const installedOuterVersion = launcherPayloadContextValid ? await resolveInstalledOuterVersion(config) : null; + reinstallRequirement = launcherPayloadContextValid + ? remoteRequiresReinstall(body, config, installedOuterVersion) ?? undefined + : undefined; + if (reinstallRequirement != null) { logUpdateEvent("reseed-required-installer-route", { currentVersion: config.currentVersion, + installedVersion: reinstallRequirement.installedVersion, + minVersion: reinstallRequirement.minVersion, + reason: reinstallRequirement.reason, supportedLauncherSchema: LAUNCHER_SCHEMA_VERSION, }); } - const selected = selectUpdateCandidateWithFallback(body, config, launcherPayloadContextValid && !reseedRequired); + const selected = selectUpdateCandidateWithFallback(body, config, launcherPayloadContextValid && reinstallRequirement == null); if (!selected.ok) { return selected.state === DESKTOP_UPDATE_STATES.ERROR ? setFailurePreservingActive(selected.error) : setState(selected.state, selected.error); } - if (compareVersions(selected.candidate.version, config.currentVersion) <= 0) { + // Same-version installer reinstall (disaster posture): when the installed + // outer is below min, the installer must be offered even with no newer + // release — waiting for the next release would strand broken outers. + // Clamped to min <= candidate so reinstalling actually clears the gate; + // otherwise the offer could never converge and would nag forever. + const sameVersionReinstall = + reinstallRequirement != null && + reinstallRequirement.reason !== "launcher-schema" && + reinstallRequirement.minVersion != null && + compareVersions(reinstallRequirement.minVersion, selected.candidate.version) <= 0; + if (!sameVersionReinstall && compareVersions(selected.candidate.version, config.currentVersion) <= 0) { logUpdateEvent("check-not-available", { candidateVersion: selected.candidate.version }); candidate = null; if (activeRelease != null) { @@ -3238,6 +3560,7 @@ export function createDesktopUpdater( async function requestPayloadRelaunch( updateRoot: string, launchPath: string, + delegated?: { generation: number; version: string }, ): Promise { if (config.openDryRun) return {}; if (config.platform !== "darwin" && config.platform !== "win32") return {}; @@ -3253,6 +3576,7 @@ export function createDesktopUpdater( const result = await launchAppAfterQuit({ appPid: processPid, cwd: config.runtimeBase, + ...(delegated == null ? {} : { delegated }), launchPath, root: updateRoot, timeoutMs: config.platform === "win32" ? WINDOWS_DEFERRED_INSTALLER_TIMEOUT_MS : MAC_DEFERRED_INSTALLER_TIMEOUT_MS, @@ -3314,7 +3638,11 @@ export function createDesktopUpdater( now, removeLauncherPayloadRoot, }); - const relaunch = await requestPayloadRelaunch(opened.root.realRoot, activation.launchPath); + const relaunch = await requestPayloadRelaunch( + opened.root.realRoot, + activation.launchPath, + activation.runtime.active ?? undefined, + ); if (relaunch.error != null && relaunch.error.length > 0) { await markInstallObservationOpenFailed(observation, now().toISOString()); return setState(DESKTOP_UPDATE_STATES.ERROR, createError("payload-relaunch-failed", relaunch.error)); @@ -3390,8 +3718,89 @@ export function createDesktopUpdater( return await next; } + /** + * Manual disaster-recovery reset. Clears every deletable cache domain and + * the one-shot update state (downloaded release, install freeze) so the next + * check starts from a clean slate. Retained launcher versions + * (active/lastSuccessful) and a confirmed handoff journal are never touched. + * Boundary: an installer helper already spawned by a prior install is not + * cancelled — clearing after opening an installer resets the updater state + * only. + */ + async function clearCacheAndResetState(): Promise { + const unsupported = unsupportedStatus(); + if (unsupported != null) return unsupported; + logUpdateEvent("manual-cache-clear-start"); + let opened = await openStore(); + if (!opened.ok) { + // Disaster posture: a corrupt store is one of the blocking scenarios + // this action exists to recover from. Rebuild only when ownership is + // provable; otherwise surface the original store error unchanged. + if (!(await rebuildOwnedUpdateRootForManualClear(config, logger))) return opened.status; + logUpdateEvent("manual-cache-clear-store-rebuilt"); + opened = await openStore(); + if (!opened.ok) return opened.status; + } + // Reset one-shot state before any deletion: even if later cleanup steps + // fail, the UI must not stay stuck on stale downloaded/frozen state — that + // is the very blocking scenario this action exists to recover from. + await writeStoreMetadata(opened.root, { + ...opened.metadata, + active: undefined, + incoming: undefined, + installFrozen: false, + installResult: undefined, + version: STORE_METADATA_VERSION, + }); + activeRelease = null; + candidate = null; + incomingRelease = null; + installFrozen = false; + installResult = undefined; + progress = undefined; + reinstallRequirement = undefined; + + const layout = opened.root.layout; + for (const transientRoot of [layout.stagingRoot, layout.downloadsRoot]) { + const entries = await readdir(transientRoot).catch(() => [] as string[]); + for (const entry of entries) { + const target = resolve(transientRoot, entry); + if (!containsPath(transientRoot, target)) continue; + await rm(target, { force: true, recursive: true }).catch((error: unknown) => { + logger.warn("[open-design updater] failed manual transient cache cleanup", { + error: error instanceof Error ? error.message : String(error), + path: target, + }); + }); + } + } + scheduleBackCleanup(opened.root.realRoot, logger); + + const releaseSummary = await runUpdateReleaseLifecycle({ + config, + layout, + logger, + now, + reclaimStaleLock: true, + trigger: "manual", + }); + if (releaseSummary == null) { + return setState( + DESKTOP_UPDATE_STATES.ERROR, + createError("updater-lifecycle-lock-held", "update cache cleanup is blocked by an active or unverifiable lifecycle lock"), + ); + } + lifecycleSummary = releaseSummary; + + await clearLauncherStateForManualClear({ config, logger, now, removeLauncherPayloadRoot }); + + logUpdateEvent("manual-cache-clear-complete"); + return setState(DESKTOP_UPDATE_STATES.IDLE); + } + return { checkForUpdates: (options) => serialized(() => checkForCandidate(options)), + clearCache: () => serialized(clearCacheAndResetState), config, downloadUpdate: () => serialized(downloadUpdate), handle(action) { @@ -3400,6 +3809,8 @@ export function createDesktopUpdater( return this.status(); case "check": return this.checkForUpdates(); + case "clear-cache": + return this.clearCache(); case "download": return this.downloadUpdate(); case "install": diff --git a/apps/desktop/tests/main/preload-host-boundary.test.ts b/apps/desktop/tests/main/preload-host-boundary.test.ts index 0e6d3ceb1e..4364529ea9 100644 --- a/apps/desktop/tests/main/preload-host-boundary.test.ts +++ b/apps/desktop/tests/main/preload-host-boundary.test.ts @@ -28,6 +28,7 @@ describe("desktop preload host boundary", () => { expect(source).toContain("'--od-os-locale='"); expect(source).toContain("osLocale"); expect(source).toContain("invokeUpdater('install'"); + expect(source).toContain("invokeUpdater('clear-cache'"); expect(source).toContain("od:update:quit"); expect(source).toContain("od:update:status-changed"); expect(source).toContain("od:update:open-dialog"); diff --git a/apps/desktop/tests/main/updater-host-boundary.test.ts b/apps/desktop/tests/main/updater-host-boundary.test.ts index 331ed3064d..fd8a4a3f58 100644 --- a/apps/desktop/tests/main/updater-host-boundary.test.ts +++ b/apps/desktop/tests/main/updater-host-boundary.test.ts @@ -16,6 +16,7 @@ describe("desktop updater host boundary", () => { const runtime = source("src/main/runtime.ts"); expect(runtime).toContain("od:update:status"); expect(runtime).toContain("od:update:check"); + expect(runtime).toContain("od:update:clear-cache"); expect(runtime).toContain("od:update:download"); expect(runtime).toContain("od:update:install"); expect(runtime).toContain("od:update:quit"); diff --git a/apps/desktop/tests/main/updater.test.ts b/apps/desktop/tests/main/updater.test.ts index a5a8672bd5..d4f0cfd1f5 100644 --- a/apps/desktop/tests/main/updater.test.ts +++ b/apps/desktop/tests/main/updater.test.ts @@ -27,6 +27,7 @@ import { createDesktopUpdaterScheduler, DESKTOP_UPDATE_ENV, resolveDesktopUpdaterConfig, + resolveInstalledOuterVersion, } from "../../src/main/updater.js"; import { installerObservationSummaryPath } from "../../src/main/installer-observations.js"; @@ -88,6 +89,7 @@ async function createUpdaterFixture(options: { artifactBody?: string; channel?: FixtureChannel; controlLauncherVersionMin?: string; + controlLauncherVersionUrl?: string; failArtifactAttempts?: number; failFirstArtifactWithTerminated?: boolean; includePayload?: boolean; @@ -127,8 +129,17 @@ async function createUpdaterFixture(options: { channel, ...channelMetadata(channel, version), ...(options.launcherSchema != null ? { launcher: { schema: options.launcherSchema } } : {}), - ...(options.controlLauncherVersionMin != null - ? { control: { launcher: { version: { min: options.controlLauncherVersionMin } } } } + ...(options.controlLauncherVersionMin != null || options.controlLauncherVersionUrl != null + ? { + control: { + launcher: { + version: { + ...(options.controlLauncherVersionMin != null ? { min: options.controlLauncherVersionMin } : {}), + ...(options.controlLauncherVersionUrl != null ? { url: options.controlLauncherVersionUrl } : {}), + }, + }, + }, + } : {}), platforms: { [platformKey]: { @@ -810,6 +821,7 @@ describe("desktop updater", () => { const launcherLaunchPath = join(root, "installed", "Open Design Beta.exe"); const logger = { error: vi.fn(), info: vi.fn(), warn: vi.fn() }; let extractCount = 0; + const relaunchInputs: Array<{ delegated?: { generation: number; version: string } }> = []; try { await mkdir(join(root, "installed"), { recursive: true }); await writeFile(launcherLaunchPath, ""); @@ -830,6 +842,7 @@ describe("desktop updater", () => { ...updaterEnv(fixture.metadataUrl, "win32"), [DESKTOP_UPDATE_ENV.CHANNEL]: DESKTOP_UPDATE_CHANNELS.BETA, [DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.0-beta.1", + [DESKTOP_UPDATE_ENV.OPEN_DRY_RUN]: "0", }, launcherLaunchPath, launcherRoot: root, @@ -841,6 +854,10 @@ describe("desktop updater", () => { extractCount += 1; await writeLauncherPayloadFixture(destinationRoot, "1.0.0-beta.2"); }, + launchAppAfterQuit: async (input) => { + relaunchInputs.push(input); + return { helperLogPath: join(root, "updates", "helpers", "relaunch.log") }; + }, logger, removeLauncherPayloadRoot: failLauncherPayloadRemovalForVersion("1.0.0-beta.0"), }); @@ -860,6 +877,15 @@ describe("desktop updater", () => { active: { generation: 2, version: "1.0.0-beta.2" }, lastSuccessful: { generation: 1, version: "1.0.0-beta.1" }, }); + // Activation pre-arms the launch attempt and hands the relaunch the + // delegated pointer: a payload that dies before its own bookkeeping + // still leaves rollback evidence, while the healthy payload recognizes + // the pre-armed attempt as its own launch in progress. + expect(JSON.parse(await readFile(launcherPaths.attemptsPath, "utf8"))).toMatchObject({ + generation: 2, + version: "1.0.0-beta.2", + }); + expect(relaunchInputs[0]?.delegated).toEqual({ generation: 2, version: "1.0.0-beta.2" }); const cleanup = JSON.parse(await readFile(launcherPaths.cleanupPath, "utf8")) as { versions: Array<{ error?: { code?: string; message?: string }; state: string; version: string }>; }; @@ -881,7 +907,12 @@ describe("desktop updater", () => { async function runLauncherReseedCheck( fixtureOptions: Parameters[0], currentVersion = "1.0.0-beta.1", - ): Promise<{ close: () => Promise; snapshot: Awaited["checkForUpdates"]>> }> { + harnessOptions: { env?: NodeJS.ProcessEnv; installedOuterVersion?: string | null; restart?: boolean } = {}, + ): Promise<{ + close: () => Promise; + restartedSnapshot?: Awaited["status"]>>; + snapshot: Awaited["checkForUpdates"]>>; + }> { const root = makeRoot(); const fixture = await createUpdaterFixture({ channel: "beta", @@ -894,6 +925,19 @@ describe("desktop updater", () => { const launcherLaunchPath = join(root, "installed", "Open Design Beta.exe"); await mkdir(join(root, "installed"), { recursive: true }); await writeFile(launcherLaunchPath, ""); + // The physically installed outer bundle's config, read by the updater to + // learn the outer version. Defaults to the running version (the fresh + // install equivalence); null omits the file to simulate an unreadable + // outer bundle. + const installedOuterVersion = + harnessOptions.installedOuterVersion === undefined ? currentVersion : harnessOptions.installedOuterVersion; + if (installedOuterVersion != null) { + await mkdir(join(root, "installed", "resources"), { recursive: true }); + await writeFile( + join(root, "installed", "resources", "open-design-config.json"), + `${JSON.stringify({ appVersion: installedOuterVersion })}\n`, + ); + } await mkdir(join(root, "launcher"), { recursive: true }); await writeFile( launcherRuntimePath, @@ -905,7 +949,7 @@ describe("desktop updater", () => { schemaVersion: LAUNCHER_SCHEMA_VERSION, })}\n`, ); - const updater = createDesktopUpdater({ + const updaterInput = { arch: "x64", currentVersion, downloadRoot: join(root, "updates"), @@ -913,13 +957,15 @@ describe("desktop updater", () => { ...updaterEnv(fixture.metadataUrl, "win32"), [DESKTOP_UPDATE_ENV.CURRENT_VERSION]: currentVersion, [DESKTOP_UPDATE_ENV.OPEN_DRY_RUN]: "0", + ...harnessOptions.env, }, launcherRoot: root, launcherLaunchPath, launcherRuntimePath, namespace: "release-beta-win", source: SIDECAR_SOURCES.PACKAGED, - }, { + } as const; + const updaterDeps: NonNullable[1]> = { extractLauncherPayloadArchive: async ({ destinationRoot }) => { await mkdir(join(destinationRoot, "payload", "resources", "open-design"), { recursive: true }); await writeFile(join(destinationRoot, "payload", "Open Design.exe"), ""); @@ -940,10 +986,15 @@ describe("desktop updater", () => { launchAppAfterQuit: async () => ({ helperLogPath: join(root, "updates", "helpers", "test.log") }), processExecPath: "C:\\Program Files\\Open Design Beta\\Open Design Beta.exe", processPid: 4242, - }); + }; + const updater = createDesktopUpdater(updaterInput, updaterDeps); const snapshot = await updater.checkForUpdates(); + const restartedSnapshot = harnessOptions.restart === true + ? await createDesktopUpdater(updaterInput, updaterDeps).status() + : undefined; return { snapshot, + ...(restartedSnapshot == null ? {} : { restartedSnapshot }), close: async () => { await fixture.close(); rmSync(root, { force: true, recursive: true }); @@ -982,11 +1033,493 @@ describe("desktop updater", () => { expect(snapshot.artifact?.type).toBe("payload"); expect(snapshot.capabilities.canApplyInPlace).toBe(true); expect(snapshot.capabilities.requiresManualInstall).toBe(false); + expect(snapshot.reinstall).toBeUndefined(); } finally { await close(); } }); + // Stone 2 — the min gate must compare against the PHYSICALLY INSTALLED outer + // package version, not the running payload version. After a payload update the + // running version is the payload's; a broken outer generation would otherwise + // slip through the gate exactly when the installer recovery path matters most. + it("compares control.launcher.version.min against the installed outer version, not the running version", async () => { + const { snapshot, close } = await runLauncherReseedCheck( + { controlLauncherVersionMin: "1.0.0-beta.1" }, + "1.0.0-beta.1", + { installedOuterVersion: "1.0.0-beta.0" }, + ); + try { + expect(snapshot.artifact?.type).toBe("installer"); + expect(snapshot.capabilities.canApplyInPlace).toBe(false); + expect(snapshot.reinstall).toMatchObject({ + installedVersion: "1.0.0-beta.0", + minVersion: "1.0.0-beta.1", + reason: "outer-below-min", + }); + } finally { + await close(); + } + }); + + it("offers a same-version installer reinstall when the outer is below min and no newer release exists", async () => { + const { snapshot, close } = await runLauncherReseedCheck( + { controlLauncherVersionMin: "1.0.0-beta.1", version: "1.0.0-beta.1" }, + "1.0.0-beta.1", + { installedOuterVersion: "1.0.0-beta.0" }, + ); + try { + expect(snapshot.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + expect(snapshot.artifact?.type).toBe("installer"); + expect(snapshot.availableVersion).toBe("1.0.0-beta.1"); + expect(snapshot.reinstall?.reason).toBe("outer-below-min"); + } finally { + await close(); + } + }); + + it("restores a downloaded same-version installer reinstall after restart", async () => { + const { restartedSnapshot, close } = await runLauncherReseedCheck( + { + controlLauncherVersionMin: "1.0.0-beta.1", + controlLauncherVersionUrl: "https://example.com/reinstall-help", + version: "1.0.0-beta.1", + }, + "1.0.0-beta.1", + { installedOuterVersion: "1.0.0-beta.0", restart: true }, + ); + try { + expect(restartedSnapshot).toMatchObject({ + availableVersion: "1.0.0-beta.1", + reinstall: { + installedVersion: "1.0.0-beta.0", + minVersion: "1.0.0-beta.1", + reason: "outer-below-min", + url: "https://example.com/reinstall-help", + }, + state: DESKTOP_UPDATE_STATES.DOWNLOADED, + }); + expect(restartedSnapshot?.artifact?.type).toBe("installer"); + expect(restartedSnapshot?.downloadPath).toEqual(expect.any(String)); + } finally { + await close(); + } + }); + + it("suppresses the same-version reinstall offer when min exceeds the latest release", async () => { + // Reinstalling to the latest release could not clear the gate, so offering + // it would nag forever. Artifact selection still routes to the installer for + // genuinely newer releases; the same-version bypass alone is suppressed. + const { snapshot, close } = await runLauncherReseedCheck( + { controlLauncherVersionMin: "9.9.9", version: "1.0.0-beta.1" }, + "1.0.0-beta.1", + { installedOuterVersion: "1.0.0-beta.0" }, + ); + try { + expect(snapshot.state).toBe(DESKTOP_UPDATE_STATES.NOT_AVAILABLE); + } finally { + await close(); + } + }); + + it("treats an unreadable installed outer config as requiring the installer when min is set", async () => { + const { snapshot, close } = await runLauncherReseedCheck( + { controlLauncherVersionMin: "0.9.0-beta.1" }, + "1.0.0-beta.1", + { installedOuterVersion: null }, + ); + try { + expect(snapshot.artifact?.type).toBe("installer"); + expect(snapshot.capabilities.canApplyInPlace).toBe(false); + expect(snapshot.reinstall?.reason).toBe("outer-version-unreadable"); + expect(snapshot.reinstall?.installedVersion).toBeUndefined(); + } finally { + await close(); + } + }); + + it("honors OD_UPDATE_INSTALLED_VERSION over the on-disk outer config", async () => { + // On-disk outer config satisfies min; the env override forces an older + // outer identity for tests and harnesses. + const { snapshot, close } = await runLauncherReseedCheck( + { controlLauncherVersionMin: "1.0.0-beta.1" }, + "1.0.0-beta.1", + { env: { [DESKTOP_UPDATE_ENV.INSTALLED_VERSION]: "1.0.0-beta.0" } }, + ); + try { + expect(snapshot.artifact?.type).toBe("installer"); + expect(snapshot.reinstall).toMatchObject({ + installedVersion: "1.0.0-beta.0", + reason: "outer-below-min", + }); + } finally { + await close(); + } + }); + + it("carries the control url into the reinstall snapshot", async () => { + const { snapshot, close } = await runLauncherReseedCheck( + { + controlLauncherVersionMin: "1.0.0-beta.1", + controlLauncherVersionUrl: "https://example.com/reinstall-help", + }, + "1.0.0-beta.1", + { installedOuterVersion: "1.0.0-beta.0" }, + ); + try { + expect(snapshot.reinstall?.url).toBe("https://example.com/reinstall-help"); + } finally { + await close(); + } + }); + + it("resolves the installed outer version from the platform bundle layout", async () => { + const root = makeRoot(); + try { + const appRoot = join(root, "Open Design.app"); + await mkdir(join(appRoot, "Contents", "Resources"), { recursive: true }); + await writeFile(join(appRoot, "Contents", "Resources", "open-design-config.json"), '{"appVersion":"0.7.0"}\n'); + const macConfig = resolveDesktopUpdaterConfig({ + env: {}, + launcherLaunchPath: appRoot, + platform: "darwin", + source: SIDECAR_SOURCES.PACKAGED, + }); + expect(await resolveInstalledOuterVersion(macConfig)).toBe("0.7.0"); + + const winExe = join(root, "win-install", "Open Design Beta.exe"); + await mkdir(join(root, "win-install", "resources"), { recursive: true }); + await writeFile(winExe, ""); + await writeFile(join(root, "win-install", "resources", "open-design-config.json"), '{"appVersion":"0.8.0-beta.2"}\n'); + const winConfig = resolveDesktopUpdaterConfig({ + env: {}, + launcherLaunchPath: winExe, + platform: "win32", + source: SIDECAR_SOURCES.PACKAGED, + }); + expect(await resolveInstalledOuterVersion(winConfig)).toBe("0.8.0-beta.2"); + + const malformedExe = join(root, "broken-install", "Open Design.exe"); + await mkdir(join(root, "broken-install", "resources"), { recursive: true }); + await writeFile(malformedExe, ""); + await writeFile(join(root, "broken-install", "resources", "open-design-config.json"), "not json\n"); + const malformedConfig = resolveDesktopUpdaterConfig({ + env: {}, + launcherLaunchPath: malformedExe, + platform: "win32", + source: SIDECAR_SOURCES.PACKAGED, + }); + expect(await resolveInstalledOuterVersion(malformedConfig)).toBeNull(); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + // Stone 3 — manual cache clear: the disaster-recovery action must reset the + // one-shot updater state (downloaded release, install freeze) and purge the + // deletable cache domains while never touching retained launcher versions. + it("clears cached releases and resets one-shot state through clear-cache", 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); + const installed = await updater.installUpdate(); + expect(installed.installResult?.dryRun).toBe(true); + + const cleared = await updater.clearCache(); + + expect(cleared.state).toBe(DESKTOP_UPDATE_STATES.IDLE); + expect(cleared.active).toBeUndefined(); + expect(cleared.downloadPath).toBeUndefined(); + expect(cleared.installResult).toBeUndefined(); + expect(cleared.cache?.lifecycle?.lastTrigger).toBe("manual"); + const storeMetadata = JSON.parse(await readFile(join(root, "metadata.json"), "utf8")) as Record; + expect(storeMetadata.active).toBeUndefined(); + expect(storeMetadata.installFrozen).not.toBe(true); + expect(storeMetadata.installResult).toBeUndefined(); + expect(await readdir(join(root, "releases"))).toEqual([]); + + // Install freeze is gone: a fresh check re-offers and re-downloads. + const rechecked = await updater.checkForUpdates(); + expect(rechecked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + expect(rechecked.downloadPath).toEqual(expect.any(String)); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + + it("reclaims a dead-owner lifecycle lock during manual cache clear", 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 mkdir(join(root, "state", "lock"), { recursive: true }); + await writeFile(join(root, "state", "lock", "owner.json"), JSON.stringify({ + createdAt: "2026-01-01T00:00:00.000Z", + owner: "open-design-updater-lifecycle", + pid: 2_147_483_647, + version: 1, + })); + + const cleared = await updater.clearCache(); + + expect(cleared.state).toBe(DESKTOP_UPDATE_STATES.IDLE); + expect(existsSync(join(root, "state", "lock"))).toBe(false); + expect(await readdir(join(root, "releases"))).toEqual([]); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + + it("clears stale launcher state and non-retained payload versions through clear-cache", async () => { + const root = makeRoot(); + try { + const launcherPaths = resolveLauncherPaths({ + channel: "beta", + namespace: "release-beta-win", + root, + }); + const launcherLaunchPath = join(root, "installed", "Open Design Beta.exe"); + await mkdir(join(root, "installed"), { recursive: true }); + await writeFile(launcherLaunchPath, ""); + await mkdir(launcherPaths.stateRoot, { recursive: true }); + await writeFile( + launcherPaths.runtimePath, + `${JSON.stringify({ + active: { generation: 1, version: "1.0.0-beta.1" }, + channel: "beta", + lastSuccessful: { generation: 0, version: "1.0.0-beta.0" }, + namespace: "release-beta-win", + schemaVersion: LAUNCHER_SCHEMA_VERSION, + })}\n`, + ); + // Stale in-flight state: an attempt that never confirmed and a handoff + // journal stranded before its terminal state. + await writeFile( + launcherPaths.attemptsPath, + `${JSON.stringify({ + channel: "beta", + generation: 1, + namespace: "release-beta-win", + schemaVersion: LAUNCHER_SCHEMA_VERSION, + version: "1.0.0-beta.1", + })}\n`, + ); + await writeFile(launcherPaths.handoffPath, `${JSON.stringify({ state: "prepared" })}\n`); + for (const version of ["0.9.0-beta.5", "1.0.0-beta.0", "1.0.0-beta.1"]) { + await mkdir(join(launcherPaths.versionsRoot, version, "payload"), { recursive: true }); + } + + const updater = createDesktopUpdater({ + arch: "x64", + currentVersion: "1.0.0-beta.1", + downloadRoot: join(root, "updates"), + env: { + ...updaterEnv("http://127.0.0.1:9/metadata.json", "win32"), + [DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]: "0", + [DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.0-beta.1", + }, + launcherLaunchPath, + launcherRoot: root, + launcherRuntimePath: launcherPaths.runtimePath, + namespace: "release-beta-win", + source: SIDECAR_SOURCES.PACKAGED, + }); + + const cleared = await updater.clearCache(); + + expect(cleared.state).toBe(DESKTOP_UPDATE_STATES.IDLE); + expect(existsSync(launcherPaths.attemptsPath)).toBe(false); + expect(existsSync(launcherPaths.handoffPath)).toBe(false); + expect(existsSync(join(launcherPaths.versionsRoot, "0.9.0-beta.5"))).toBe(false); + // Versions retained by runtime pointers must survive a manual clear. + expect(existsSync(join(launcherPaths.versionsRoot, "1.0.0-beta.1"))).toBe(true); + expect(existsSync(join(launcherPaths.versionsRoot, "1.0.0-beta.0"))).toBe(true); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + // Stone 4 — clear-cache must survive a corrupt store when ownership is + // provable: the sentinel is the proof, and everything else in an owned root + // is updater cache by definition. Unowned or foreign-generation roots are + // never touched. + it("rebuilds an owned update store with corrupt metadata through clear-cache", async () => { + const root = makeRoot(); + try { + await writeFile(join(root, ".open-design-updater-root.json"), JSON.stringify({ + createdAt: "2026-01-01T00:00:00.000Z", + owner: "open-design-updater", + source: "tools-pack", + version: 1, + })); + await writeFile(join(root, "metadata.json"), "{ not json"); + await mkdir(join(root, "releases", "stale-release"), { recursive: true }); + + const updater = createDesktopUpdater({ + arch: "arm64", + downloadRoot: root, + env: updaterEnv("http://127.0.0.1:9/metadata.json"), + source: SIDECAR_SOURCES.TOOLS_PACK, + }); + + const cleared = await updater.clearCache(); + + expect(cleared.state).toBe(DESKTOP_UPDATE_STATES.IDLE); + expect(cleared.error).toBeUndefined(); + const storeMetadata = JSON.parse(await readFile(join(root, "metadata.json"), "utf8")) as Record; + expect(storeMetadata.version).toBe(1); + expect(existsSync(join(root, "releases", "stale-release"))).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("rebuilds an owned update store with unexpected root entries through clear-cache", async () => { + const root = makeRoot(); + try { + await writeFile(join(root, ".open-design-updater-root.json"), JSON.stringify({ + createdAt: "2026-01-01T00:00:00.000Z", + owner: "open-design-updater", + source: "tools-pack", + version: 1, + })); + await writeFile(join(root, "metadata.json"), JSON.stringify({ version: 1 })); + await writeFile(join(root, "stray-file.bin"), "junk"); + + const updater = createDesktopUpdater({ + arch: "arm64", + downloadRoot: root, + env: updaterEnv("http://127.0.0.1:9/metadata.json"), + source: SIDECAR_SOURCES.TOOLS_PACK, + }); + + const cleared = await updater.clearCache(); + + expect(cleared.state).toBe(DESKTOP_UPDATE_STATES.IDLE); + expect(existsSync(join(root, "stray-file.bin"))).toBe(false); + expect(existsSync(join(root, ".open-design-updater-root.json"))).toBe(true); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("refuses to clear a non-empty root without an ownership sentinel", async () => { + const root = makeRoot(); + try { + await writeFile(join(root, "user-document.txt"), "precious"); + + const updater = createDesktopUpdater({ + arch: "arm64", + downloadRoot: root, + env: updaterEnv("http://127.0.0.1:9/metadata.json"), + source: SIDECAR_SOURCES.TOOLS_PACK, + }); + + const cleared = await updater.clearCache(); + + expect(cleared.state).toBe(DESKTOP_UPDATE_STATES.ERROR); + expect(cleared.error?.code).toBe("update-root-not-owned"); + expect(await readFile(join(root, "user-document.txt"), "utf8")).toBe("precious"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("refuses to clear a root whose ownership marker belongs to another updater generation", async () => { + const root = makeRoot(); + try { + await writeFile(join(root, ".open-design-updater-root.json"), JSON.stringify({ + owner: "open-design-updater", + version: 999, + })); + await writeFile(join(root, "metadata.json"), "{ not json"); + + const updater = createDesktopUpdater({ + arch: "arm64", + downloadRoot: root, + env: updaterEnv("http://127.0.0.1:9/metadata.json"), + source: SIDECAR_SOURCES.TOOLS_PACK, + }); + + const cleared = await updater.clearCache(); + + expect(cleared.state).toBe(DESKTOP_UPDATE_STATES.ERROR); + expect(cleared.error?.code).toBe("update-root-version-mismatch"); + expect(await readFile(join(root, "metadata.json"), "utf8")).toBe("{ not json"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("keeps a confirmed desktop handoff journal through clear-cache", async () => { + const root = makeRoot(); + try { + const launcherPaths = resolveLauncherPaths({ + channel: "beta", + namespace: "release-beta-win", + root, + }); + const launcherLaunchPath = join(root, "installed", "Open Design Beta.exe"); + await mkdir(join(root, "installed"), { recursive: true }); + await writeFile(launcherLaunchPath, ""); + await mkdir(launcherPaths.stateRoot, { recursive: true }); + await writeFile( + launcherPaths.runtimePath, + `${JSON.stringify({ + active: { generation: 1, version: "1.0.0-beta.1" }, + channel: "beta", + lastSuccessful: { generation: 1, version: "1.0.0-beta.1" }, + namespace: "release-beta-win", + schemaVersion: LAUNCHER_SCHEMA_VERSION, + })}\n`, + ); + // A confirmed journal is a successful terminal state consulted by + // historical-outer cold starts; a manual clear must not remove it. + await writeFile(launcherPaths.handoffPath, `${JSON.stringify({ state: "confirmed" })}\n`); + + const updater = createDesktopUpdater({ + arch: "x64", + currentVersion: "1.0.0-beta.1", + downloadRoot: join(root, "updates"), + env: { + ...updaterEnv("http://127.0.0.1:9/metadata.json", "win32"), + [DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]: "0", + [DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.0-beta.1", + }, + launcherLaunchPath, + launcherRoot: root, + launcherRuntimePath: launcherPaths.runtimePath, + namespace: "release-beta-win", + source: SIDECAR_SOURCES.PACKAGED, + }); + + const cleared = await updater.clearCache(); + + expect(cleared.state).toBe(DESKTOP_UPDATE_STATES.IDLE); + expect(existsSync(launcherPaths.handoffPath)).toBe(true); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + it("rejects launcher payloads that change before activation", async () => { const root = makeRoot(); const fixture = await createUpdaterFixture({ @@ -3253,6 +3786,88 @@ describe("desktop updater", () => { } }); + it("clears a stale payload install freeze when the promised relaunch never became the running version", async () => { + // A payload install records installResult.activeVersion and freezes the + // updater awaiting the relaunch. If the payload crashed and the launcher + // rolled back, the app restarts on the OLD version: the freeze is then + // stale, and keeping it would silence every future check on the + // rolled-back install. The downloaded release itself stays actionable. + const root = makeRoot(); + const fixture = await createUpdaterFixture({ + channel: "beta", + includePayload: true, + payloadBody: "open design rollback freeze payload fixture", + platform: "win", + version: "1.0.0-beta.2", + }); + const launcherPaths = resolveLauncherPaths({ + channel: "beta", + namespace: "release-beta-win", + root, + }); + const launcherLaunchPath = join(root, "installed", "Open Design Beta.exe"); + try { + await mkdir(join(root, "installed"), { recursive: true }); + await writeFile(launcherLaunchPath, ""); + await mkdir(launcherPaths.stateRoot, { recursive: true }); + await writeFile(launcherPaths.runtimePath, `${JSON.stringify({ + active: { generation: 0, version: "1.0.0-beta.1" }, + channel: "beta", + lastSuccessful: { generation: 0, version: "1.0.0-beta.1" }, + namespace: "release-beta-win", + schemaVersion: LAUNCHER_SCHEMA_VERSION, + })}\n`); + const updaterInput = { + arch: "x64", + currentVersion: "1.0.0-beta.1", + downloadRoot: join(root, "updates"), + env: { + ...updaterEnv(fixture.metadataUrl, "win32"), + [DESKTOP_UPDATE_ENV.CHANNEL]: DESKTOP_UPDATE_CHANNELS.BETA, + [DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.0-beta.1", + [DESKTOP_UPDATE_ENV.OPEN_DRY_RUN]: "0", + }, + launcherLaunchPath, + launcherRoot: root, + launcherRuntimePath: launcherPaths.runtimePath, + namespace: "release-beta-win", + source: SIDECAR_SOURCES.PACKAGED, + } as const; + const updaterDeps = { + extractLauncherPayloadArchive: async ({ destinationRoot }: { destinationRoot: string }) => { + await writeLauncherPayloadFixture(destinationRoot, "1.0.0-beta.2"); + }, + launchAppAfterQuit: async () => ({ helperLogPath: join(root, "updates", "helpers", "relaunch.log") }), + }; + const updater = createDesktopUpdater(updaterInput, updaterDeps); + const checked = await updater.checkForUpdates(); + expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + const installed = await updater.installUpdate(); + expect(installed.installResult?.activeVersion).toBe("1.0.0-beta.2"); + + // Relaunch crashed; the launcher rolled back; the app restarts still on + // 1.0.0-beta.1. The stale freeze must not survive the restore. + const restarted = createDesktopUpdater(updaterInput, updaterDeps); + const restored = await restarted.status(); + + expect(restored.installResult).toBeUndefined(); + expect(restored.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + expect(restored.downloadPath).toEqual(expect.any(String)); + const store = JSON.parse(await readFile(join(root, "updates", "metadata.json"), "utf8")) as Record; + expect(store.installFrozen).not.toBe(true); + expect(store.installResult).toBeUndefined(); + + // Checks are alive again: the updater re-derives the offer instead of + // returning the frozen snapshot. + const rechecked = await restarted.checkForUpdates(); + expect(rechecked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED); + expect(rechecked.installResult).toBeUndefined(); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + it("clears interrupted incoming downloads on cold start instead of surfacing a store error", async () => { const root = makeRoot(); const fixture = await createUpdaterFixture({ platform: "win" }); diff --git a/apps/packaged/src/index.ts b/apps/packaged/src/index.ts index 508988938e..635d8e9cc9 100644 --- a/apps/packaged/src/index.ts +++ b/apps/packaged/src/index.ts @@ -7,6 +7,7 @@ import { } from "@open-design/sidecar-proto"; import { parseLauncherAfterQuitArgs, + parseLauncherDelegatedArgs, parseLauncherHandoffResumeArgs, } from "@open-design/launcher-proto"; import { @@ -116,6 +117,7 @@ async function main(): Promise { const config = await readPackagedConfig(); const afterQuit = parseLauncherAfterQuitArgs(process.argv.slice(1)); const handoffResume = parseLauncherHandoffResumeArgs(process.argv.slice(1)); + const delegated = parseLauncherDelegatedArgs(process.argv.slice(1)); const argvStamp = readProcessStamp(process.argv.slice(1), OPEN_DESIGN_SIDECAR_CONTRACT); const namespace = argvStamp?.namespace ?? config.namespace; const namespaceConfig = namespace === config.namespace ? config : { ...config, namespace }; @@ -134,6 +136,7 @@ async function main(): Promise { } const stamp = argvStamp ?? createPackagedDesktopStamp(namespace); const launcherRuntime = await resolvePackagedLauncherRuntime(namespaceConfig, initialPaths, { + delegated, resume: handoffResume, }); if (await launchPackagedPayloadDesktop(launcherRuntime, stamp)) { diff --git a/apps/packaged/src/launcher-runtime.ts b/apps/packaged/src/launcher-runtime.ts index 64b06afd81..2e862ae501 100644 --- a/apps/packaged/src/launcher-runtime.ts +++ b/apps/packaged/src/launcher-runtime.ts @@ -87,6 +87,12 @@ type ResolvedPayloadConfig = { export type ResolvePackagedLauncherRuntimeOptions = { currentExecutablePath?: string; + /** + * Pointer from `--od-launcher-delegated-*` argv: the spawning parent + * pre-armed attempt.json for this pointer, so a matching attempt marks the + * launch in progress rather than a previous failure. + */ + delegated?: LauncherVersionPointer | null; resume?: LauncherHandoffResumeRequest | null; }; @@ -472,7 +478,12 @@ export async function resolvePackagedLauncherRuntime( sameExecutablePath(currentExecutablePath, handoff.payloadExecutablePath) ? handoff.target : null; - const selection = selectLauncherRuntimeTarget({ attempted, resume: requestedResume, runtime: descriptor }); + const selection = selectLauncherRuntimeTarget({ + attempted, + delegated: options.delegated ?? null, + resume: requestedResume, + runtime: descriptor, + }); const persistedInstall = await readLauncherInstallDescriptor(launcherPaths, channel, config.namespace).catch(() => null); const currentPackageLaunchPath = stableAppLaunchPathFromExecutable(process.execPath); @@ -552,7 +563,15 @@ async function writeJsonFile(path: string, payload: unknown): Promise { await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); } -export async function recordPackagedLauncherRuntimeFailedAttempt( +/** + * Arm attempt.json for a normal active delegation BEFORE the payload spawns. + * A payload that dies before reaching its own launcher bookkeeping then still + * leaves rollback evidence, so the next cold start rolls back to + * lastSuccessful instead of retrying the broken payload forever. Rollback + * (last-successful) delegations are deliberately excluded: the attempt on + * disk IS the rollback evidence and must not be overwritten. + */ +export async function armPackagedLauncherRuntimeAttempt( runtime: PackagedLauncherRuntime, ): Promise { if (runtime.source !== "payload") return; @@ -567,12 +586,20 @@ export async function recordPackagedLauncherRuntimeFailedAttempt( } satisfies LauncherAttemptDescriptor); } +export async function recordPackagedLauncherRuntimeFailedAttempt( + runtime: PackagedLauncherRuntime, +): Promise { + await armPackagedLauncherRuntimeAttempt(runtime); +} + export async function confirmPackagedLauncherRuntime(runtime: PackagedLauncherRuntime): Promise { if (runtime.source !== "payload") return; if (!runtime.payloadDesktopProcess) return; if (runtime.desktopExecutablePath == null) return; if (!runtime.selection.selected || ( - runtime.selection.reason !== "active" && runtime.selection.reason !== "active-resume" + runtime.selection.reason !== "active" && + runtime.selection.reason !== "active-delegated" && + runtime.selection.reason !== "active-resume" )) return; const confirmedAt = new Date().toISOString(); const next: LauncherRuntimeDescriptor = { diff --git a/apps/packaged/src/payload-desktop-launch.ts b/apps/packaged/src/payload-desktop-launch.ts index bdc619a990..770fbd9d28 100644 --- a/apps/packaged/src/payload-desktop-launch.ts +++ b/apps/packaged/src/payload-desktop-launch.ts @@ -1,11 +1,12 @@ import { spawn } from "node:child_process"; import { dirname } from "node:path"; -import { buildLauncherAfterQuitArgs } from "@open-design/launcher-proto"; +import { buildLauncherAfterQuitArgs, buildLauncherDelegatedArgs } from "@open-design/launcher-proto"; import { createProcessStampArgs } from "@open-design/platform"; import { OPEN_DESIGN_SIDECAR_CONTRACT, type SidecarStamp } from "@open-design/sidecar-proto"; import { + armPackagedLauncherRuntimeAttempt, recordPackagedLauncherRuntimeFailedAttempt, type PackagedLauncherRuntime, } from "./launcher-runtime.js"; @@ -35,6 +36,14 @@ export function planPackagedPayloadDesktopDelegation( targetPid: options.currentPid ?? process.pid, timeoutMs: options.timeoutMs ?? DEFAULT_DELEGATION_TIMEOUT_MS, }), + // A normal active delegation is pre-armed by the parent, so the child + // needs the delegated pointer to tell its own in-progress attempt apart + // from a previous failed launch. A rollback (last-successful) + // delegation deliberately carries no marker: the attempt on disk is the + // rollback evidence and the child must re-derive the rollback from it. + ...(runtime.selection.selected && runtime.selection.reason === "active" + ? buildLauncherDelegatedArgs(runtime.selection.pointer) + : []), ...createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), ], command: runtime.desktopExecutablePath, @@ -55,6 +64,10 @@ export async function launchPackagedPayloadDesktop( const plan = planPackagedPayloadDesktopDelegation(runtime, stamp, options); if (plan == null) return false; + // Pre-arm BEFORE spawn: a payload that spawns successfully but dies before + // reaching its own launcher bookkeeping would otherwise leave no rollback + // evidence, and every later cold start would retry the same broken payload. + await armPackagedLauncherRuntimeAttempt(runtime); try { const child = (options.spawn ?? spawn)(plan.command, plan.args, { cwd: plan.cwd, diff --git a/apps/packaged/src/sidecars.ts b/apps/packaged/src/sidecars.ts index ff39e2538b..6b94ac6f7a 100644 --- a/apps/packaged/src/sidecars.ts +++ b/apps/packaged/src/sidecars.ts @@ -59,6 +59,30 @@ const PACKAGED_CHILD_ENV_ALLOWLIST = [ "no_proxy", ] as const; +// The daemon owns the historical-outer compatibility handoff. Preserve the +// updater controls it needs to launch the replacement payload desktop with the +// same feed/test policy as the outer process, without broadening the packaged +// child environment allowlist. +const PACKAGED_DESKTOP_HANDOFF_ENV_KEYS = [ + "OD_UPDATE_ARCH", + "OD_UPDATE_AUTO_CHECK", + "OD_UPDATE_AUTO_DOWNLOAD", + "OD_UPDATE_AUTO_OPEN", + "OD_UPDATE_CHANNEL", + "OD_UPDATE_CHECK_BACKOFF_INITIAL_MS", + "OD_UPDATE_CHECK_BACKOFF_MAX_MS", + "OD_UPDATE_CHECK_INITIAL_DELAY_MS", + "OD_UPDATE_CHECK_INTERVAL_MS", + "OD_UPDATE_CURRENT_VERSION", + "OD_UPDATE_DOWNLOAD_ROOT", + "OD_UPDATE_ENABLED", + "OD_UPDATE_INSTALLED_VERSION", + "OD_UPDATE_METADATA_URL", + "OD_UPDATE_MODE", + "OD_UPDATE_OPEN_DRY_RUN", + "OD_UPDATE_PLATFORM", +] as const; + function shouldForwardPackagedChildEnv(key: string, includeProviderSecrets = false): boolean { return ( PACKAGED_CHILD_ENV_ALLOWLIST.includes( @@ -387,6 +411,7 @@ export type PackagedDaemonSpawnEnvOptions = { appVersion: string | null; amrProfile?: string | null; daemonCliEntry: string | null; + desktopHandoffEnv?: NodeJS.ProcessEnv; nodeCommand?: string | null; /** * PR #974 round-5 (lefarcen P2): only pin the daemon's import-folder @@ -437,6 +462,7 @@ export function buildPackagedDaemonSpawnEnv( ? {} : { OPEN_DESIGN_AMR_PROFILE: options.amrProfile }), ...(options.appVersion == null ? {} : { OD_APP_VERSION: options.appVersion }), + ...pickPackagedDesktopHandoffEnv(options.desktopHandoffEnv ?? {}), ...(options.telemetryRelayUrl == null || options.telemetryRelayUrl.length === 0 ? {} : { OPEN_DESIGN_TELEMETRY_RELAY_URL: options.telemetryRelayUrl }), @@ -463,6 +489,15 @@ export function buildPackagedDaemonSpawnEnv( }; } +function pickPackagedDesktopHandoffEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const selected: NodeJS.ProcessEnv = {}; + for (const key of PACKAGED_DESKTOP_HANDOFF_ENV_KEYS) { + const value = env[key]; + if (value != null && value.length > 0) selected[key] = value; + } + return selected; +} + async function spawnSidecarChild(options: { app: AppKey; electronNodeCommand: string | null; @@ -643,6 +678,7 @@ export async function startPackagedSidecars( appVersion: options.appVersion, amrProfile: options.amrProfile, daemonCliEntry: options.daemonCliEntry, + desktopHandoffEnv: process.env, legacyDataDir: process.env.OD_LEGACY_DATA_DIR ?? null, nodeCommand: options.nodeCommand, requireDesktopAuth: options.requireDesktopAuth, diff --git a/apps/packaged/tests/payload-desktop-launch.test.ts b/apps/packaged/tests/payload-desktop-launch.test.ts index c035c166cc..1c036c924f 100644 --- a/apps/packaged/tests/payload-desktop-launch.test.ts +++ b/apps/packaged/tests/payload-desktop-launch.test.ts @@ -1,8 +1,9 @@ +import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { parseLauncherAfterQuitArgs } from "@open-design/launcher-proto"; +import { parseLauncherAfterQuitArgs, parseLauncherDelegatedArgs } from "@open-design/launcher-proto"; import { readProcessStamp } from "@open-design/platform"; import { APP_KEYS, @@ -66,6 +67,115 @@ describe("payload desktop delegation", () => { expect(readProcessStamp(plan?.args ?? [], OPEN_DESIGN_SIDECAR_CONTRACT)).toEqual(stamp); }); + it("carries the delegated pointer for a normal active delegation", () => { + const plan = planPackagedPayloadDesktopDelegation(fakeRuntime(false), stamp, { + currentPid: 4321, + timeoutMs: 60_000, + }); + expect(parseLauncherDelegatedArgs(plan?.args ?? [])).toEqual({ + generation: 1, + version: "1.2.3-beta.5", + }); + }); + + it("omits the delegated pointer for a rollback delegation", () => { + // A last-successful delegation is driven by rollback evidence in + // attempt.json; marking it delegated (or re-arming) would let the spawned + // payload re-select the broken active generation. + const runtime: PackagedLauncherRuntime = { + ...fakeRuntime(false), + selection: { + pointer: { generation: 0, version: "1.2.3-beta.4" }, + reason: "last-successful", + selected: true, + }, + }; + const plan = planPackagedPayloadDesktopDelegation(runtime, stamp, { + currentPid: 4321, + timeoutMs: 60_000, + }); + expect(plan).not.toBeNull(); + expect(parseLauncherDelegatedArgs(plan?.args ?? [])).toBeNull(); + }); + + it("pre-arms the launch attempt before spawning the delegated payload", async () => { + // A payload that dies before reaching its own bookkeeping must still + // leave rollback evidence — the parent arms attempt.json BEFORE spawn so + // the next cold start rolls back instead of retrying the broken payload + // forever. + const root = await mkdtemp(join(tmpdir(), "od-delegated-arm-")); + try { + const attemptsPath = join(root, "state", "attempt.json"); + const runtime: PackagedLauncherRuntime = { + ...fakeRuntime(false), + launcherPaths: { + attemptsPath, + channel: "beta", + namespace: "release-beta", + } as PackagedLauncherRuntime["launcherPaths"], + }; + let spawnSawAttempt: boolean | null = null; + const child = { + once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (event === "spawn") queueMicrotask(callback); + return child; + }), + unref: vi.fn(), + }; + const spawnFake = vi.fn(() => { + spawnSawAttempt = existsSync(attemptsPath); + return child as never; + }); + + const launched = await launchPackagedPayloadDesktop(runtime, stamp, { spawn: spawnFake as never }); + + expect(launched).toBe(true); + expect(spawnSawAttempt).toBe(true); + const attempt = JSON.parse(await readFile(attemptsPath, "utf8")) as Record; + expect(attempt).toMatchObject({ + channel: "beta", + generation: 1, + namespace: "release-beta", + version: "1.2.3-beta.5", + }); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("does not pre-arm a rollback delegation", async () => { + const root = await mkdtemp(join(tmpdir(), "od-delegated-rollback-")); + try { + const attemptsPath = join(root, "state", "attempt.json"); + const runtime: PackagedLauncherRuntime = { + ...fakeRuntime(false), + launcherPaths: { + attemptsPath, + channel: "beta", + namespace: "release-beta", + } as PackagedLauncherRuntime["launcherPaths"], + selection: { + pointer: { generation: 0, version: "1.2.3-beta.4" }, + reason: "last-successful", + selected: true, + }, + }; + const child = { + once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (event === "spawn") queueMicrotask(callback); + return child; + }), + unref: vi.fn(), + }; + + await launchPackagedPayloadDesktop(runtime, stamp, { spawn: vi.fn(() => child as never) as never }); + + expect(existsSync(attemptsPath)).toBe(false); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it("does not delegate once the current process already is the payload desktop", () => { expect(planPackagedPayloadDesktopDelegation(fakeRuntime(true), stamp, { currentPid: 4321, @@ -74,25 +184,38 @@ describe("payload desktop delegation", () => { }); it("waits for spawn acceptance and detaches the payload child", async () => { - const once = vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "spawn") queueMicrotask(callback); - return child; - }); - const child = { - once, - unref: vi.fn(), - }; - const spawn = vi.fn(() => child); + const root = await mkdtemp(join(tmpdir(), "od-delegated-spawn-")); + try { + const once = vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (event === "spawn") queueMicrotask(callback); + return child; + }); + const child = { + once, + unref: vi.fn(), + }; + const spawn = vi.fn(() => child); + const runtime: PackagedLauncherRuntime = { + ...fakeRuntime(false), + launcherPaths: { + attemptsPath: join(root, "state", "attempt.json"), + channel: "beta", + namespace: "release-beta", + } as PackagedLauncherRuntime["launcherPaths"], + }; - const delegated = await launchPackagedPayloadDesktop(fakeRuntime(false), stamp, { - currentPid: 4321, - spawn: spawn as never, - timeoutMs: 60_000, - }); + const delegated = await launchPackagedPayloadDesktop(runtime, stamp, { + currentPid: 4321, + spawn: spawn as never, + timeoutMs: 60_000, + }); - expect(delegated).toBe(true); - expect(spawn).toHaveBeenCalledOnce(); - expect(child.unref).toHaveBeenCalledOnce(); + expect(delegated).toBe(true); + expect(spawn).toHaveBeenCalledOnce(); + expect(child.unref).toHaveBeenCalledOnce(); + } finally { + await rm(root, { force: true, recursive: true }); + } }); it("records a failed attempt when the payload executable cannot spawn", async () => { diff --git a/apps/packaged/tests/sidecars.test.ts b/apps/packaged/tests/sidecars.test.ts index 63fdb362bb..ccbb2eb6a2 100644 --- a/apps/packaged/tests/sidecars.test.ts +++ b/apps/packaged/tests/sidecars.test.ts @@ -389,6 +389,26 @@ describe('buildPackagedDaemonSpawnEnv', () => { expect(env.OD_LEGACY_DATA_DIR).toBeUndefined(); }); + it('forwards updater controls needed by a historical desktop handoff', () => { + const env = buildPackagedDaemonSpawnEnv(fakePaths(), { + appVersion: '1.2.3', + daemonCliEntry: null, + desktopHandoffEnv: { + OD_UPDATE_CURRENT_VERSION: '1.2.3', + OD_UPDATE_INSTALLED_VERSION: '1.0.0', + OD_UPDATE_METADATA_URL: 'http://127.0.0.1:54321/stable/latest/metadata.json', + PATH: 'must-not-leak-through-handoff-env', + }, + legacyDataDir: null, + requireDesktopAuth: true, + }); + + expect(env.OD_UPDATE_CURRENT_VERSION).toBe('1.2.3'); + expect(env.OD_UPDATE_INSTALLED_VERSION).toBe('1.0.0'); + expect(env.OD_UPDATE_METADATA_URL).toBe('http://127.0.0.1:54321/stable/latest/metadata.json'); + expect(env.PATH).toBeUndefined(); + }); + it('omits OD_REQUIRE_DESKTOP_AUTH entirely when requireDesktopAuth=false (headless)', () => { const env = buildPackagedDaemonSpawnEnv(fakePaths(), { appVersion: null, diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index c2830a8cfa..36a162b2f9 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -140,6 +140,7 @@ import type { MediaProvider } from '../media/models'; import { Toast } from './Toast'; import { checkForUpdaterUpdate, + clearUpdaterCache, deriveUpdaterModel, downloadUpdaterUpdate, openUpdaterInstaller, @@ -1798,6 +1799,9 @@ export function SettingsDialog({ const [aboutUpdateActionBusy, setAboutUpdateActionBusy] = useState(false); const [aboutUpdateQuitFailed, setAboutUpdateQuitFailed] = useState(false); const [aboutToast, setAboutToast] = useState(null); + // Two-stage inline confirm for the destructive manual cache clear. + const [clearUpdaterCacheStage, setClearUpdaterCacheStage] = useState<'idle' | 'confirm'>('idle'); + const [clearUpdaterCacheBusy, setClearUpdaterCacheBusy] = useState(false); useEffect(() => { let mounted = true; @@ -1907,6 +1911,28 @@ export function SettingsDialog({ void openExternalUrl(OPEN_DESIGN_RELEASES_URL); }, []); + // Manual updater/launcher cache clear — the disaster-recovery action for + // stuck update state. The desktop owns the capability; this handler only + // reports the outcome and refreshes the About updater model. + const handleClearUpdaterCache = useCallback(() => { + if (clearUpdaterCacheBusy) return; + setClearUpdaterCacheBusy(true); + void (async () => { + try { + const result = await clearUpdaterCache(); + if (result.ok) { + setAboutUpdaterModel(result.model); + setAboutToast(t('settings.clearUpdaterCacheSuccess')); + } else { + setAboutToast(t('settings.clearUpdaterCacheFailed')); + } + } finally { + setClearUpdaterCacheBusy(false); + setClearUpdaterCacheStage('idle'); + } + })(); + }, [clearUpdaterCacheBusy, t]); + // Precise inverse of App.handleCompleteOnboarding: flip // onboardingCompleted back to false, mirror it to localStorage and the // daemon through the same config-persist path, then route the user into @@ -5903,6 +5929,41 @@ export function SettingsDialog({ + {aboutUpdaterModel.environment === 'desktop' + && aboutUpdaterModel.supported + && appVersionInfo?.packaged !== false ? ( +
+
+

{t('settings.clearUpdaterCacheTitle')}

+

{t('settings.clearUpdaterCacheHint')}

+
+ {clearUpdaterCacheStage === 'confirm' ? ( + <> + + + + ) : ( + + )} +
+ ) : null}

{t('diagnostics.exportTitle')}

diff --git a/apps/web/src/components/UpdateDialog.tsx b/apps/web/src/components/UpdateDialog.tsx index 651259f27d..617df56120 100644 --- a/apps/web/src/components/UpdateDialog.tsx +++ b/apps/web/src/components/UpdateDialog.tsx @@ -336,6 +336,13 @@ export function UpdateDialog() { if (status?.error != null && restartSafetyFromUpdaterStatus(status) == null) { return state === 'error' ? t('updater.dialogCheckFailed') : t('settings.updateActionFailed'); } + // A forced installer reinstall reads differently from a routine update: + // the same copy covers both the not-yet-downloaded and ready states. + if ((ready || available) && model.reinstall != null) { + return model.availableVersion == null + ? t('updater.reinstallReadyGeneric') + : t('updater.reinstallReadyVersion', { version: model.availableVersion }); + } if (ready) { if (model.availableVersion != null) { return t('updater.dialogReadyVersion', { version: model.availableVersion }); @@ -366,6 +373,7 @@ export function UpdateDialog() { })(); const showSafety = restartSafety != null; + const reinstallUrl = model.reinstall?.url ?? null; const title = showSafety ? t('updater.activeRunsTitle') : t('settings.updateCheck'); const primaryLabel = (() => { if (ready) return model.updateKind === 'payload' ? t('updater.installRestart') : t('updater.openInstaller'); @@ -417,13 +425,24 @@ export function UpdateDialog() { ) : null} {!showSafety && (available || ready) ? (
- + {reinstallUrl != null ? ( + + ) : ( + + )}
) : null}
diff --git a/apps/web/src/components/UpdaterPopup.tsx b/apps/web/src/components/UpdaterPopup.tsx index d4d2e2e18f..d8b40dc685 100644 --- a/apps/web/src/components/UpdaterPopup.tsx +++ b/apps/web/src/components/UpdaterPopup.tsx @@ -4,6 +4,7 @@ import type { OpenDesignHostUpdaterStatusSnapshot } from '@open-design/host'; import { Icon } from './Icon'; import { popoverIn } from '../motion'; +import { openExternalUrl } from '../providers/registry'; import { deriveUpdaterModel, openUpdaterInstaller, @@ -43,6 +44,9 @@ type UpdaterPopupProps = { function versionText(t: Translator, model: UpdaterModel): string { const version = model.availableVersion; + if (model.reinstall != null) { + return version == null ? t('updater.reinstallReadyGeneric') : t('updater.reinstallReadyVersion', { version }); + } if (model.updateKind === 'payload') { return version == null ? t('updater.payloadReadyGeneric') : t('updater.payloadReadyVersion', { version }); } @@ -475,6 +479,19 @@ export function UpdaterPopup({ ); } +function ReinstallLearnMoreLink({ t, url }: { t: Translator; url: string }) { + return ( + + ); +} + function UpdaterPopupPanel({ allowSilentUpdatesChecked, channelLabel, @@ -522,6 +539,9 @@ function UpdaterPopupPanel({ {quitRecoverable && model.updateKind === 'payload' ? null :

{quitRecoverable ? t('updater.quitFailedBody') : versionText(t, model)}

} + {!quitRecoverable && model.reinstall?.url != null ? ( + + ) : null} {channelLabel != null ? {channelLabel} : null} {installError != null ? (

diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index eeae590fb3..69213505b0 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -3690,6 +3690,9 @@ export const ar: Dict = { 'updater.ready': 'التحديث جاهز', 'updater.readyGeneric': 'إصدار جديد جاهز. سيتم إغلاق Open Design وفتح المثبت.', 'updater.readyVersion': 'Open Design {version} جاهز. سيتم إغلاق Open Design وفتح المثبت.', + 'updater.reinstallLearnMore': 'معرفة المزيد', + 'updater.reinstallReadyGeneric': 'يتطلب هذا التحديث إعادة تثبيت كاملة. سيتم إغلاق Open Design وفتح المثبّت.', + 'updater.reinstallReadyVersion': 'يتطلب Open Design {version} إعادة تثبيت كاملة. سيتم إغلاق Open Design وفتح المثبّت.', 'updater.restartAnyway': 'إعادة التشغيل على أي حال', 'updater.upToDate': 'أنت تستخدم أحدث إصدار بالفعل.', 'updater.viewVersionFeatures': 'استكشف ميزات الإصدار الجديد', @@ -3995,6 +3998,12 @@ export const ar: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'مسح ذاكرة التخزين المؤقت للتحديث', + 'settings.clearUpdaterCacheHint': 'يزيل التحديثات التي تم تنزيلها وذاكرات التخزين المؤقت القديمة، ثم يعيد تعيين حالة التحديث. استخدمه عندما تبدو التحديثات عالقة.', + 'settings.clearUpdaterCacheButton': 'مسح ذاكرة التخزين المؤقت', + 'settings.clearUpdaterCacheConfirmButton': 'تأكيد المسح', + 'settings.clearUpdaterCacheSuccess': 'تم مسح ذاكرة التخزين المؤقت للتحديث.', + 'settings.clearUpdaterCacheFailed': 'تعذر مسح ذاكرة التخزين المؤقت للتحديث.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index ac6e6d9f6f..151fad854e 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -3690,6 +3690,9 @@ export const de: Dict = { 'updater.ready': 'Update bereit', 'updater.readyGeneric': 'Eine neue Version ist bereit. Open Design wird geschlossen und der Installer geöffnet.', 'updater.readyVersion': 'Open Design {version} ist bereit. Open Design wird geschlossen und der Installer geöffnet.', + 'updater.reinstallLearnMore': 'Mehr erfahren', + 'updater.reinstallReadyGeneric': 'Dieses Update erfordert eine vollständige Neuinstallation. Open Design wird geschlossen und der Installer geöffnet.', + 'updater.reinstallReadyVersion': 'Open Design {version} erfordert eine vollständige Neuinstallation. Open Design wird geschlossen und der Installer geöffnet.', 'updater.restartAnyway': 'Trotzdem neu starten', 'updater.upToDate': 'Sie verwenden bereits die neueste Version.', 'updater.viewVersionFeatures': 'Neue Funktionen entdecken', @@ -3995,6 +3998,12 @@ export const de: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Update-Cache leeren', + 'settings.clearUpdaterCacheHint': 'Entfernt heruntergeladene Updates und veraltete Updater-Caches und setzt den Update-Status zurück. Verwenden Sie dies, wenn Updates festzuhängen scheinen.', + 'settings.clearUpdaterCacheButton': 'Cache leeren', + 'settings.clearUpdaterCacheConfirmButton': 'Leeren bestätigen', + 'settings.clearUpdaterCacheSuccess': 'Update-Cache geleert.', + 'settings.clearUpdaterCacheFailed': 'Update-Cache konnte nicht geleert werden.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 220d17cdd9..932ee24027 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -3766,6 +3766,9 @@ export const en: Dict = { 'updater.ready': 'Update ready', 'updater.readyGeneric': 'A new version is ready. Open Design will close and open the installer.', 'updater.readyVersion': 'Open Design {version} is ready. Open Design will close and open the installer.', + 'updater.reinstallLearnMore': 'Learn more', + 'updater.reinstallReadyGeneric': 'This update requires a full reinstall. Open Design will close and open the installer.', + 'updater.reinstallReadyVersion': 'Open Design {version} requires a full reinstall. Open Design will close and open the installer.', 'updater.restartAnyway': 'Restart anyway', 'updater.upToDate': 'You\'re already on the latest version.', 'updater.viewVersionFeatures': 'Explore new features', @@ -4009,6 +4012,12 @@ export const en: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Clear update cache', + 'settings.clearUpdaterCacheHint': 'Removes downloaded updates and stale updater caches, then resets update state. Use this when updates appear stuck.', + 'settings.clearUpdaterCacheButton': 'Clear cache', + 'settings.clearUpdaterCacheConfirmButton': 'Confirm clear', + 'settings.clearUpdaterCacheSuccess': 'Update cache cleared.', + 'settings.clearUpdaterCacheFailed': 'Couldn\'t clear the update cache.', 'onboarding.brandTitle': 'Extract your design system', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable design system for future work.', 'onboarding.brandSkip': 'Skip', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index b10e2970fb..5a9711a800 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -3690,6 +3690,9 @@ export const esES: Dict = { 'updater.ready': 'Actualización lista', 'updater.readyGeneric': 'Hay una nueva versión lista. Open Design se cerrará y abrirá el instalador.', 'updater.readyVersion': 'Open Design {version} está listo. Open Design se cerrará y abrirá el instalador.', + 'updater.reinstallLearnMore': 'Más información', + 'updater.reinstallReadyGeneric': 'Esta actualización requiere una reinstalación completa. Open Design se cerrará y abrirá el instalador.', + 'updater.reinstallReadyVersion': 'Open Design {version} requiere una reinstalación completa. Open Design se cerrará y abrirá el instalador.', 'updater.restartAnyway': 'Reiniciar de todos modos', 'updater.upToDate': 'Ya tienes la versión más reciente.', 'updater.viewVersionFeatures': 'Descubrir las novedades', @@ -3995,6 +3998,12 @@ export const esES: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Borrar caché de actualización', + 'settings.clearUpdaterCacheHint': 'Elimina las actualizaciones descargadas y las cachés obsoletas del actualizador, y restablece el estado de actualización. Úsalo cuando las actualizaciones parezcan atascadas.', + 'settings.clearUpdaterCacheButton': 'Borrar caché', + 'settings.clearUpdaterCacheConfirmButton': 'Confirmar borrado', + 'settings.clearUpdaterCacheSuccess': 'Caché de actualización borrada.', + 'settings.clearUpdaterCacheFailed': 'No se pudo borrar la caché de actualización.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 85b004b140..04827052e8 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -3618,6 +3618,9 @@ export const fa: Dict = { 'updater.ready': 'به‌روزرسانی آماده است', 'updater.readyGeneric': 'نسخه جدید آماده است. Open Design بسته می‌شود و نصب‌کننده را باز می‌کند.', 'updater.readyVersion': 'Open Design {version} آماده است. Open Design بسته می‌شود و نصب‌کننده را باز می‌کند.', + 'updater.reinstallLearnMore': 'اطلاعات بیشتر', + 'updater.reinstallReadyGeneric': 'این به‌روزرسانی به نصب مجدد کامل نیاز دارد. Open Design بسته می‌شود و نصب‌کننده باز خواهد شد.', + 'updater.reinstallReadyVersion': 'Open Design {version} به نصب مجدد کامل نیاز دارد. Open Design بسته می‌شود و نصب‌کننده باز خواهد شد.', 'updater.restartAnyway': 'به‌هرحال راه‌اندازی مجدد شود', 'updater.upToDate': 'شما هم‌اکنون آخرین نسخه را دارید.', 'updater.viewVersionFeatures': 'مشاهده قابلیت‌های نسخه جدید', @@ -3990,6 +3993,12 @@ export const fa: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'پاک‌سازی حافظهٔ پنهان به‌روزرسانی', + 'settings.clearUpdaterCacheHint': 'به‌روزرسانی‌های دانلودشده و حافظه‌های پنهان قدیمی را حذف کرده و وضعیت به‌روزرسانی را بازنشانی می‌کند. زمانی استفاده کنید که به‌روزرسانی‌ها گیر کرده به نظر می‌رسند.', + 'settings.clearUpdaterCacheButton': 'پاک‌سازی حافظهٔ پنهان', + 'settings.clearUpdaterCacheConfirmButton': 'تأیید پاک‌سازی', + 'settings.clearUpdaterCacheSuccess': 'حافظهٔ پنهان به‌روزرسانی پاک شد.', + 'settings.clearUpdaterCacheFailed': 'پاک‌سازی حافظهٔ پنهان به‌روزرسانی ممکن نشد.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index f39ca68796..7776255902 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -3752,6 +3752,9 @@ export const fr: Dict = { 'updater.ready': 'Mise à jour prête', 'updater.readyGeneric': 'Une nouvelle version est prête. Open Design va se fermer et ouvrir le programme d’installation.', 'updater.readyVersion': 'Open Design {version} est prêt. Open Design va se fermer et ouvrir le programme d’installation.', + 'updater.reinstallLearnMore': 'En savoir plus', + 'updater.reinstallReadyGeneric': 'Cette mise à jour nécessite une réinstallation complète. Open Design va se fermer et ouvrir l\'installateur.', + 'updater.reinstallReadyVersion': 'Open Design {version} nécessite une réinstallation complète. Open Design va se fermer et ouvrir l\'installateur.', 'updater.restartAnyway': 'Redémarrer quand même', 'updater.upToDate': 'Vous utilisez déjà la dernière version.', 'updater.viewVersionFeatures': 'Découvrir les nouveautés', @@ -3995,6 +3998,12 @@ export const fr: Dict = { 'settings.resetOnboarding': 'Réinitialiser la configuration initiale', 'settings.resetOnboardingDesc': 'Relancez la configuration initiale, y compris l’extraction de marque.', 'settings.resetOnboardingButton': 'Réinitialiser', + 'settings.clearUpdaterCacheTitle': 'Vider le cache de mise à jour', + 'settings.clearUpdaterCacheHint': 'Supprime les mises à jour téléchargées et les caches obsolètes, puis réinitialise l\'état de mise à jour. À utiliser lorsque les mises à jour semblent bloquées.', + 'settings.clearUpdaterCacheButton': 'Vider le cache', + 'settings.clearUpdaterCacheConfirmButton': 'Confirmer le vidage', + 'settings.clearUpdaterCacheSuccess': 'Cache de mise à jour vidé.', + 'settings.clearUpdaterCacheFailed': 'Impossible de vider le cache de mise à jour.', 'onboarding.brandTitle': 'Extraire votre système de design', 'onboarding.brandSubtitle': 'Collez un site web et nous transformerons ses signaux de marque en système de design réutilisable pour vos prochains travaux.', 'onboarding.brandSkip': 'Ignorer', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index c56318930a..df86306e74 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -3690,6 +3690,9 @@ export const hu: Dict = { 'updater.ready': 'Frissítés kész', 'updater.readyGeneric': 'Elkészült az új verzió. Az Open Design bezárul, és megnyitja a telepítőt.', 'updater.readyVersion': 'Az Open Design {version} elkészült. Az Open Design bezárul, és megnyitja a telepítőt.', + 'updater.reinstallLearnMore': 'További információ', + 'updater.reinstallReadyGeneric': 'Ez a frissítés teljes újratelepítést igényel. Az Open Design bezárul, és megnyitja a telepítőt.', + 'updater.reinstallReadyVersion': 'Az Open Design {version} teljes újratelepítést igényel. Az Open Design bezárul, és megnyitja a telepítőt.', 'updater.restartAnyway': 'Újraindítás mindenképp', 'updater.upToDate': 'Már a legújabb verziót használja.', 'updater.viewVersionFeatures': 'Új funkciók felfedezése', @@ -3995,6 +3998,12 @@ export const hu: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Frissítési gyorsítótár törlése', + 'settings.clearUpdaterCacheHint': 'Eltávolítja a letöltött frissítéseket és az elavult gyorsítótárakat, majd visszaállítja a frissítési állapotot. Akkor használja, ha a frissítések elakadtak.', + 'settings.clearUpdaterCacheButton': 'Gyorsítótár törlése', + 'settings.clearUpdaterCacheConfirmButton': 'Törlés megerősítése', + 'settings.clearUpdaterCacheSuccess': 'A frissítési gyorsítótár törölve.', + 'settings.clearUpdaterCacheFailed': 'Nem sikerült törölni a frissítési gyorsítótárat.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index 1792291303..28e58b9c33 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -3752,6 +3752,9 @@ export const id: Dict = { 'updater.ready': 'Update siap', 'updater.readyGeneric': 'Versi baru siap. Open Design akan ditutup dan membuka installer.', 'updater.readyVersion': 'Open Design {version} siap. Open Design akan ditutup dan membuka installer.', + 'updater.reinstallLearnMore': 'Pelajari lebih lanjut', + 'updater.reinstallReadyGeneric': 'Pembaruan ini memerlukan instal ulang penuh. Open Design akan menutup dan membuka pemasang.', + 'updater.reinstallReadyVersion': 'Open Design {version} memerlukan instal ulang penuh. Open Design akan menutup dan membuka pemasang.', 'updater.restartAnyway': 'Tetap mulai ulang', 'updater.upToDate': 'Anda sudah menggunakan versi terbaru.', 'updater.viewVersionFeatures': 'Lihat fitur versi baru', @@ -3995,6 +3998,12 @@ export const id: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Bersihkan cache pembaruan', + 'settings.clearUpdaterCacheHint': 'Menghapus pembaruan yang diunduh dan cache pembaruan usang, lalu mengatur ulang status pembaruan. Gunakan saat pembaruan tampak macet.', + 'settings.clearUpdaterCacheButton': 'Bersihkan cache', + 'settings.clearUpdaterCacheConfirmButton': 'Konfirmasi pembersihan', + 'settings.clearUpdaterCacheSuccess': 'Cache pembaruan dibersihkan.', + 'settings.clearUpdaterCacheFailed': 'Tidak dapat membersihkan cache pembaruan.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index b7feff1601..c59d4ca3a4 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -3690,6 +3690,9 @@ export const it: Dict = { 'updater.ready': 'Aggiornamento pronto', 'updater.readyGeneric': 'È pronta una nuova versione. Open Design si chiuderà e aprirà il programma di installazione.', 'updater.readyVersion': 'Open Design {version} è pronto. Open Design si chiuderà e aprirà il programma di installazione.', + 'updater.reinstallLearnMore': 'Scopri di più', + 'updater.reinstallReadyGeneric': 'Questo aggiornamento richiede una reinstallazione completa. Open Design si chiuderà e aprirà il programma di installazione.', + 'updater.reinstallReadyVersion': 'Open Design {version} richiede una reinstallazione completa. Open Design si chiuderà e aprirà il programma di installazione.', 'updater.restartAnyway': 'Riavvia comunque', 'updater.upToDate': 'Hai già la versione più recente.', 'updater.viewVersionFeatures': 'Scopri le novità', @@ -3995,6 +3998,12 @@ export const it: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Svuota cache aggiornamenti', + 'settings.clearUpdaterCacheHint': 'Rimuove gli aggiornamenti scaricati e le cache obsolete, quindi reimposta lo stato di aggiornamento. Da usare quando gli aggiornamenti sembrano bloccati.', + 'settings.clearUpdaterCacheButton': 'Svuota cache', + 'settings.clearUpdaterCacheConfirmButton': 'Conferma svuotamento', + 'settings.clearUpdaterCacheSuccess': 'Cache aggiornamenti svuotata.', + 'settings.clearUpdaterCacheFailed': 'Impossibile svuotare la cache aggiornamenti.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index ef89d07800..b1245671c3 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -3690,6 +3690,9 @@ export const ja: Dict = { 'updater.ready': 'アップデート準備完了', 'updater.readyGeneric': '新しいバージョンの準備ができました。Open Design を閉じてインストーラーを開きます。', 'updater.readyVersion': 'Open Design {version} の準備ができました。Open Design を閉じてインストーラーを開きます。', + 'updater.reinstallLearnMore': '詳細を見る', + 'updater.reinstallReadyGeneric': 'このアップデートには完全な再インストールが必要です。Open Design を終了してインストーラーを開きます。', + 'updater.reinstallReadyVersion': 'Open Design {version} には完全な再インストールが必要です。Open Design を終了してインストーラーを開きます。', 'updater.restartAnyway': '再起動する', 'updater.upToDate': 'すでに最新バージョンです。', 'updater.viewVersionFeatures': '新バージョンの機能を見る', @@ -3995,6 +3998,12 @@ export const ja: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'アップデートキャッシュをクリア', + 'settings.clearUpdaterCacheHint': 'ダウンロード済みのアップデートと古いアップデートキャッシュを削除し、更新状態をリセットします。アップデートが止まっているように見えるときに使用してください。', + 'settings.clearUpdaterCacheButton': 'キャッシュをクリア', + 'settings.clearUpdaterCacheConfirmButton': 'クリアを確認', + 'settings.clearUpdaterCacheSuccess': 'アップデートキャッシュをクリアしました。', + 'settings.clearUpdaterCacheFailed': 'アップデートキャッシュをクリアできませんでした。', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index b5b52f17a5..97926e5981 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -3690,6 +3690,9 @@ export const ko: Dict = { 'updater.ready': '업데이트 준비됨', 'updater.readyGeneric': '새 버전이 준비되었습니다. Open Design을 닫고 설치 프로그램을 엽니다.', 'updater.readyVersion': 'Open Design {version}이 준비되었습니다. Open Design을 닫고 설치 프로그램을 엽니다.', + 'updater.reinstallLearnMore': '자세히 알아보기', + 'updater.reinstallReadyGeneric': '이 업데이트는 전체 재설치가 필요합니다. Open Design이 종료되고 설치 프로그램이 열립니다.', + 'updater.reinstallReadyVersion': 'Open Design {version}은(는) 전체 재설치가 필요합니다. Open Design이 종료되고 설치 프로그램이 열립니다.', 'updater.restartAnyway': '그래도 다시 시작', 'updater.upToDate': '이미 최신 버전입니다.', 'updater.viewVersionFeatures': '새 버전 기능 보기', @@ -3995,6 +3998,12 @@ export const ko: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': '업데이트 캐시 지우기', + 'settings.clearUpdaterCacheHint': '다운로드된 업데이트와 오래된 업데이트 캐시를 제거하고 업데이트 상태를 초기화합니다. 업데이트가 멈춘 것처럼 보일 때 사용하세요.', + 'settings.clearUpdaterCacheButton': '캐시 지우기', + 'settings.clearUpdaterCacheConfirmButton': '지우기 확인', + 'settings.clearUpdaterCacheSuccess': '업데이트 캐시를 지웠습니다.', + 'settings.clearUpdaterCacheFailed': '업데이트 캐시를 지우지 못했습니다.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index f3840dedf5..2577f115d9 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -3690,6 +3690,9 @@ export const pl: Dict = { 'updater.ready': 'Aktualizacja gotowa', 'updater.readyGeneric': 'Nowa wersja jest gotowa. Open Design zamknie się i otworzy instalator.', 'updater.readyVersion': 'Open Design {version} jest gotowy. Open Design zamknie się i otworzy instalator.', + 'updater.reinstallLearnMore': 'Dowiedz się więcej', + 'updater.reinstallReadyGeneric': 'Ta aktualizacja wymaga pełnej ponownej instalacji. Open Design zamknie się i otworzy instalator.', + 'updater.reinstallReadyVersion': 'Open Design {version} wymaga pełnej ponownej instalacji. Open Design zamknie się i otworzy instalator.', 'updater.restartAnyway': 'Uruchom ponownie mimo to', 'updater.upToDate': 'Masz już najnowszą wersję.', 'updater.viewVersionFeatures': 'Poznaj nowe funkcje', @@ -3995,6 +3998,12 @@ export const pl: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Wyczyść pamięć podręczną aktualizacji', + 'settings.clearUpdaterCacheHint': 'Usuwa pobrane aktualizacje i przestarzałą pamięć podręczną, a następnie resetuje stan aktualizacji. Użyj, gdy aktualizacje wydają się zablokowane.', + 'settings.clearUpdaterCacheButton': 'Wyczyść pamięć podręczną', + 'settings.clearUpdaterCacheConfirmButton': 'Potwierdź czyszczenie', + 'settings.clearUpdaterCacheSuccess': 'Pamięć podręczna aktualizacji wyczyszczona.', + 'settings.clearUpdaterCacheFailed': 'Nie udało się wyczyścić pamięci podręcznej aktualizacji.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 1587aed77a..6074c17a6f 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -3690,6 +3690,9 @@ export const ptBR: Dict = { 'updater.ready': 'Atualização pronta', 'updater.readyGeneric': 'Uma nova versão está pronta. O Open Design será fechado e abrirá o instalador.', 'updater.readyVersion': 'Open Design {version} está pronto. O Open Design será fechado e abrirá o instalador.', + 'updater.reinstallLearnMore': 'Saiba mais', + 'updater.reinstallReadyGeneric': 'Esta atualização exige uma reinstalação completa. O Open Design será fechado e o instalador será aberto.', + 'updater.reinstallReadyVersion': 'O Open Design {version} exige uma reinstalação completa. O Open Design será fechado e o instalador será aberto.', 'updater.restartAnyway': 'Reiniciar mesmo assim', 'updater.upToDate': 'Você já está na versão mais recente.', 'updater.viewVersionFeatures': 'Conhecer os novos recursos', @@ -3995,6 +3998,12 @@ export const ptBR: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Limpar cache de atualização', + 'settings.clearUpdaterCacheHint': 'Remove atualizações baixadas e caches antigos do atualizador e redefine o estado de atualização. Use quando as atualizações parecerem travadas.', + 'settings.clearUpdaterCacheButton': 'Limpar cache', + 'settings.clearUpdaterCacheConfirmButton': 'Confirmar limpeza', + 'settings.clearUpdaterCacheSuccess': 'Cache de atualização limpo.', + 'settings.clearUpdaterCacheFailed': 'Não foi possível limpar o cache de atualização.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index e0a868348f..d6a0f10de1 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -3690,6 +3690,9 @@ export const ru: Dict = { 'updater.ready': 'Обновление готово', 'updater.readyGeneric': 'Новая версия готова. Open Design закроется и откроет установщик.', 'updater.readyVersion': 'Open Design {version} готов. Open Design закроется и откроет установщик.', + 'updater.reinstallLearnMore': 'Подробнее', + 'updater.reinstallReadyGeneric': 'Это обновление требует полной переустановки. Open Design закроется и откроет установщик.', + 'updater.reinstallReadyVersion': 'Open Design {version} требует полной переустановки. Open Design закроется и откроет установщик.', 'updater.restartAnyway': 'Всё равно перезапустить', 'updater.upToDate': 'У вас уже установлена последняя версия.', 'updater.viewVersionFeatures': 'Что нового в этой версии', @@ -3995,6 +3998,12 @@ export const ru: Dict = { 'settings.resetOnboarding': 'Сбросить onboarding', 'settings.resetOnboardingDesc': 'Повторить первичную настройку, включая извлечение бренда.', 'settings.resetOnboardingButton': 'Сбросить', + 'settings.clearUpdaterCacheTitle': 'Очистить кэш обновлений', + 'settings.clearUpdaterCacheHint': 'Удаляет загруженные обновления и устаревший кэш, затем сбрасывает состояние обновления. Используйте, если обновления кажутся зависшими.', + 'settings.clearUpdaterCacheButton': 'Очистить кэш', + 'settings.clearUpdaterCacheConfirmButton': 'Подтвердить очистку', + 'settings.clearUpdaterCacheSuccess': 'Кэш обновлений очищен.', + 'settings.clearUpdaterCacheFailed': 'Не удалось очистить кэш обновлений.', 'onboarding.brandTitle': 'Извлеките бренд-кит', 'onboarding.brandSubtitle': 'Вставьте сайт, и мы превратим сигналы бренда в переиспользуемый бренд-кит для будущих работ.', 'onboarding.brandSkip': 'Пока пропустить', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index 62a99f9c25..1e4237b913 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -3752,6 +3752,9 @@ export const th: Dict = { 'updater.ready': 'อัปเดตพร้อมแล้ว', 'updater.readyGeneric': 'เวอร์ชันใหม่พร้อมแล้ว Open Design จะปิดและเปิดตัวติดตั้ง', 'updater.readyVersion': 'Open Design {version} พร้อมแล้ว Open Design จะปิดและเปิดตัวติดตั้ง', + 'updater.reinstallLearnMore': 'เรียนรู้เพิ่มเติม', + 'updater.reinstallReadyGeneric': 'การอัปเดตนี้ต้องติดตั้งใหม่ทั้งหมด Open Design จะปิดและเปิดตัวติดตั้ง', + 'updater.reinstallReadyVersion': 'Open Design {version} ต้องติดตั้งใหม่ทั้งหมด Open Design จะปิดและเปิดตัวติดตั้ง', 'updater.restartAnyway': 'รีสตาร์ตต่อไป', 'updater.upToDate': 'คุณใช้เวอร์ชันล่าสุดอยู่แล้ว', 'updater.viewVersionFeatures': 'ดูฟีเจอร์ใหม่', @@ -3995,6 +3998,12 @@ export const th: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'ล้างแคชการอัปเดต', + 'settings.clearUpdaterCacheHint': 'ลบการอัปเดตที่ดาวน์โหลดแล้วและแคชเก่า จากนั้นรีเซ็ตสถานะการอัปเดต ใช้เมื่อการอัปเดตดูเหมือนค้าง', + 'settings.clearUpdaterCacheButton': 'ล้างแคช', + 'settings.clearUpdaterCacheConfirmButton': 'ยืนยันการล้าง', + 'settings.clearUpdaterCacheSuccess': 'ล้างแคชการอัปเดตแล้ว', + 'settings.clearUpdaterCacheFailed': 'ไม่สามารถล้างแคชการอัปเดตได้', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index d8db1c0f35..0efe9eb945 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -3690,6 +3690,9 @@ export const tr: Dict = { 'updater.ready': 'Güncelleme hazır', 'updater.readyGeneric': 'Yeni bir sürüm hazır. Open Design kapanıp yükleyiciyi açacak.', 'updater.readyVersion': 'Open Design {version} hazır. Open Design kapanıp yükleyiciyi açacak.', + 'updater.reinstallLearnMore': 'Daha fazla bilgi', + 'updater.reinstallReadyGeneric': 'Bu güncelleme tam bir yeniden kurulum gerektirir. Open Design kapanacak ve yükleyiciyi açacak.', + 'updater.reinstallReadyVersion': 'Open Design {version} tam bir yeniden kurulum gerektirir. Open Design kapanacak ve yükleyiciyi açacak.', 'updater.restartAnyway': 'Yine de yeniden başlat', 'updater.upToDate': 'Zaten en son sürümdesiniz.', 'updater.viewVersionFeatures': 'Yeni özellikleri keşfet', @@ -3995,6 +3998,12 @@ export const tr: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Güncelleme önbelleğini temizle', + 'settings.clearUpdaterCacheHint': 'İndirilen güncellemeleri ve eski güncelleyici önbelleklerini kaldırır, ardından güncelleme durumunu sıfırlar. Güncellemeler takılı görünüyorsa kullanın.', + 'settings.clearUpdaterCacheButton': 'Önbelleği temizle', + 'settings.clearUpdaterCacheConfirmButton': 'Temizlemeyi onayla', + 'settings.clearUpdaterCacheSuccess': 'Güncelleme önbelleği temizlendi.', + 'settings.clearUpdaterCacheFailed': 'Güncelleme önbelleği temizlenemedi.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index 01b8893ce6..055aab6063 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -3618,6 +3618,9 @@ export const uk: Dict = { 'updater.ready': 'Оновлення готове', 'updater.readyGeneric': 'Нова версія готова. Open Design закриється і відкриє інсталятор.', 'updater.readyVersion': 'Open Design {version} готовий. Open Design закриється і відкриє інсталятор.', + 'updater.reinstallLearnMore': 'Докладніше', + 'updater.reinstallReadyGeneric': 'Це оновлення потребує повного перевстановлення. Open Design закриється та відкриє інсталятор.', + 'updater.reinstallReadyVersion': 'Open Design {version} потребує повного перевстановлення. Open Design закриється та відкриє інсталятор.', 'updater.restartAnyway': 'Усе одно перезапустити', 'updater.upToDate': 'У вас уже встановлено найновішу версію.', 'updater.viewVersionFeatures': 'Переглянути нові функції', @@ -3995,6 +3998,12 @@ export const uk: Dict = { 'settings.resetOnboarding': 'Reset onboarding', 'settings.resetOnboardingDesc': 'Replay the first-run setup, including brand extraction.', 'settings.resetOnboardingButton': 'Reset', + 'settings.clearUpdaterCacheTitle': 'Очистити кеш оновлень', + 'settings.clearUpdaterCacheHint': 'Видаляє завантажені оновлення та застарілий кеш, а потім скидає стан оновлення. Використовуйте, якщо оновлення, схоже, зависли.', + 'settings.clearUpdaterCacheButton': 'Очистити кеш', + 'settings.clearUpdaterCacheConfirmButton': 'Підтвердити очищення', + 'settings.clearUpdaterCacheSuccess': 'Кеш оновлень очищено.', + 'settings.clearUpdaterCacheFailed': 'Не вдалося очистити кеш оновлень.', 'onboarding.brandTitle': 'Extract your brand kit', 'onboarding.brandSubtitle': 'Paste a website and we\'ll turn its brand signals into a reusable brand kit for future work.', 'onboarding.brandSkip': 'Skip for now', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index c5d86493a6..704c2cc6c2 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -3985,6 +3985,9 @@ export const zhCN: Dict = { "updater.upToDate": "您已经是最新版本啦", "updater.viewVersionFeatures": "查看新版本特性", "updater.manualDownload": "手动下载", + "updater.reinstallLearnMore": "了解详情", + "updater.reinstallReadyGeneric": "此更新需要完整重装。Open Design 将关闭并打开安装程序。", + "updater.reinstallReadyVersion": "Open Design {version} 需要完整重装。Open Design 将关闭并打开安装程序。", "updater.restartAnyway": "仍然重启", "whatsNew.cta": "看看有什么新功能", @@ -4429,6 +4432,12 @@ export const zhCN: Dict = { "settings.resetOnboarding": "重置引导流程", "settings.resetOnboardingDesc": "重新进行首次设置,包括品牌提取。", "settings.resetOnboardingButton": "重置", + "settings.clearUpdaterCacheTitle": "清理更新缓存", + "settings.clearUpdaterCacheHint": "删除已下载的更新与残留的更新缓存,并重置更新状态。当更新疑似卡住时使用。", + "settings.clearUpdaterCacheButton": "清理缓存", + "settings.clearUpdaterCacheConfirmButton": "确认清理", + "settings.clearUpdaterCacheSuccess": "更新缓存已清理。", + "settings.clearUpdaterCacheFailed": "更新缓存清理失败。", "onboarding.brandTitle": "提取你的设计系统", "onboarding.brandSubtitle": "粘贴品牌网站,我们会把品牌线索转成后续可复用的设计系统。", diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 7a3d0435c1..c5d29405db 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -3991,6 +3991,9 @@ export const zhTW: Dict = { "updater.upToDate": "您已經是最新版本啦", "updater.viewVersionFeatures": "查看新版本特色", "updater.manualDownload": "手動下載", + "updater.reinstallLearnMore": "了解詳情", + "updater.reinstallReadyGeneric": "此更新需要完整重新安裝。Open Design 將關閉並開啟安裝程式。", + "updater.reinstallReadyVersion": "Open Design {version} 需要完整重新安裝。Open Design 將關閉並開啟安裝程式。", "updater.restartAnyway": "仍然重新啟動", "whatsNew.cta": "看看有什麼新功能", @@ -4436,6 +4439,12 @@ export const zhTW: Dict = { "settings.resetOnboarding": "重置引導流程", "settings.resetOnboardingDesc": "重新進行首次設定,包括品牌擷取。", "settings.resetOnboardingButton": "重置", + "settings.clearUpdaterCacheTitle": "清理更新快取", + "settings.clearUpdaterCacheHint": "刪除已下載的更新與殘留的更新快取,並重設更新狀態。當更新疑似卡住時使用。", + "settings.clearUpdaterCacheButton": "清理快取", + "settings.clearUpdaterCacheConfirmButton": "確認清理", + "settings.clearUpdaterCacheSuccess": "更新快取已清理。", + "settings.clearUpdaterCacheFailed": "無法清理更新快取。", "onboarding.brandTitle": "擷取你的設計系統", "onboarding.brandSubtitle": "貼上品牌網站,我們會把品牌線索轉成後續可重用的設計系統。", diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 01bcc6413d..36f712c3ba 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -983,6 +983,11 @@ export interface Dict { 'updater.ready': string; 'updater.readyGeneric': string; 'updater.readyVersion': string; + // Forced installer reinstall (feed control.launcher.version.min gate). + // Copy priority: operator jump link (reinstall.url) > these defaults. + 'updater.reinstallLearnMore': string; + 'updater.reinstallReadyGeneric': string; + 'updater.reinstallReadyVersion': string; 'updater.restartAnyway': string; 'updater.upToDate': string; 'updater.viewVersionFeatures': string; @@ -4276,6 +4281,13 @@ export interface Dict { 'settings.resetOnboarding': string; 'settings.resetOnboardingDesc': string; 'settings.resetOnboardingButton': string; + // Manual updater/launcher cache clear (disaster recovery, About pane). + 'settings.clearUpdaterCacheTitle': string; + 'settings.clearUpdaterCacheHint': string; + 'settings.clearUpdaterCacheButton': string; + 'settings.clearUpdaterCacheConfirmButton': string; + 'settings.clearUpdaterCacheSuccess': string; + 'settings.clearUpdaterCacheFailed': string; 'onboarding.brandTitle': string; 'onboarding.brandSubtitle': string; 'onboarding.brandSkip': string; diff --git a/apps/web/src/lib/updater.ts b/apps/web/src/lib/updater.ts index 2f166b8931..98ccc5de38 100644 --- a/apps/web/src/lib/updater.ts +++ b/apps/web/src/lib/updater.ts @@ -1,6 +1,7 @@ import { OPEN_DESIGN_HOST_UPDATER_STATES, checkHostUpdater, + clearHostUpdaterCache, downloadHostUpdater, getHostUpdaterStatus, installHostUpdater, @@ -14,6 +15,7 @@ import { type OpenDesignHostUpdaterActionOptions, type OpenDesignHostUpdaterMenuLabels, type OpenDesignHostUpdaterOpenDialogListener, + type OpenDesignHostUpdaterReinstallSnapshot, type OpenDesignHostUpdaterResult, type OpenDesignHostUpdaterStatusListener, type OpenDesignHostUpdaterStatusSnapshot, @@ -52,6 +54,12 @@ export type UpdaterModel = { installerOpened: boolean; updateKind: 'installer' | 'payload' | 'unknown'; promptKey: string | null; + /** + * Present when the feed requires a full installer reinstall (broken or + * outdated installed outer package). UI copy priority: `reinstall.url` + * jump link > default i18n reinstall copy. + */ + reinstall: OpenDesignHostUpdaterReinstallSnapshot | null; requiresManualInstall: boolean; upToDate: boolean; shouldShowControl: boolean; @@ -157,6 +165,7 @@ export function deriveUpdaterModel( installerOpened, updateKind, promptKey, + reinstall: status?.reinstall ?? null, requiresManualInstall: Boolean(status?.capabilities.requiresManualInstall), upToDate, shouldShowControl: canInstallUpdate && hasDownloadedInstaller && !installerOpened, @@ -182,6 +191,10 @@ export async function openUpdaterInstaller(options?: OpenDesignHostUpdaterAction return modelFromHostResult(await installHostUpdater(options)); } +export async function clearUpdaterCache(options?: OpenDesignHostUpdaterActionOptions): Promise { + return modelFromHostResult(await clearHostUpdaterCache(options)); +} + export async function quitAfterUpdaterInstallerOpen( options?: OpenDesignHostUpdaterActionOptions, ): Promise { diff --git a/apps/web/src/styles/workspace/mention-home.css b/apps/web/src/styles/workspace/mention-home.css index cbe6da99c0..c9e0f40d21 100644 --- a/apps/web/src/styles/workspace/mention-home.css +++ b/apps/web/src/styles/workspace/mention-home.css @@ -388,6 +388,25 @@ line-height: 1.35; } +/* Operator-supplied reinstall explanation link (control.launcher.version.url). */ +.updater-popup__link { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 6px; + padding: 0; + border: none; + background: none; + color: var(--accent); + font-size: 12px; + line-height: 1.35; + cursor: pointer; +} + +.updater-popup__link:hover { + text-decoration: underline; +} + /* Footer stays a single row: long en labels wrap inside the checkbox column; actions stay non-shrinking on the trailing edge. */ .updater-popup__footer { diff --git a/apps/web/tests/components/ProjectView.deleteConversation.test.tsx b/apps/web/tests/components/ProjectView.deleteConversation.test.tsx index b6b9a76767..380be8a7f8 100644 --- a/apps/web/tests/components/ProjectView.deleteConversation.test.tsx +++ b/apps/web/tests/components/ProjectView.deleteConversation.test.tsx @@ -356,7 +356,7 @@ describe('ProjectView conversation delete', () => { renderProjectView(vi.fn()); await waitFor(() => expect(chatPaneProps.onSubmitQuestionForm).toBeDefined()); - expect(chatPaneProps.questionFormSubmitDisabled).toBe(false); + await waitFor(() => expect(chatPaneProps.questionFormSubmitDisabled).toBe(false)); expect(fileWorkspaceProps.questionForm).toBeUndefined(); }); }); diff --git a/apps/web/tests/components/SettingsDialog.execution.test.tsx b/apps/web/tests/components/SettingsDialog.execution.test.tsx index de2ee8e4d0..62e9b61901 100644 --- a/apps/web/tests/components/SettingsDialog.execution.test.tsx +++ b/apps/web/tests/components/SettingsDialog.execution.test.tsx @@ -5353,6 +5353,76 @@ describe('SettingsDialog about interactions', () => { expect(screen.getByText('Version 1.2.3-beta.4 is ready to install.')).toBeTruthy(); }); + it('clears the updater cache from the about page after inline confirmation', async () => { + const cleared = updateStatus({ state: 'idle' }); + const clearCache = vi.fn(async () => cleared); + restoreOpenDesignHost = installMockOpenDesignHost({ + host: { + updater: { + 'clear-cache': clearCache, + status: vi.fn(async () => updateStatus()), + }, + }, + }); + + renderSettingsDialog( + { mode: 'daemon', agentId: 'codex' }, + { + initialSection: 'about', + appVersionInfo: { + version: '1.2.3-beta.3', + channel: 'beta', + packaged: true, + platform: 'darwin', + arch: 'arm64', + }, + }, + ); + + // Two-stage inline confirm: the first click only arms the action. + const trigger = await screen.findByTestId('settings-clear-updater-cache'); + fireEvent.click(trigger); + expect(clearCache).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('settings-clear-updater-cache-confirm')); + + await waitFor(() => { + expect(clearCache).toHaveBeenCalled(); + }); + expect(await screen.findByText(en['settings.clearUpdaterCacheSuccess'])).toBeTruthy(); + }); + + it('hides updater cache recovery when packaged updates are unsupported', async () => { + restoreOpenDesignHost = installMockOpenDesignHost({ + host: { + updater: { + status: vi.fn(async () => updateStatus({ + platform: 'linux', + state: 'unsupported', + supported: false, + })), + }, + }, + }); + + renderSettingsDialog( + { mode: 'daemon', agentId: 'codex' }, + { + initialSection: 'about', + appVersionInfo: { + version: '1.2.3-beta.3', + channel: 'beta', + packaged: true, + platform: 'linux', + arch: 'x64', + }, + }, + ); + + await screen.findByText(en['settings.updateStatusUnsupported']); + expect(screen.queryByTestId('settings-clear-updater-cache')).toBeNull(); + }); + it('installs a downloaded payload update from the about page', async () => { const payloadReady = updateStatus({ artifact: { diff --git a/apps/web/tests/components/UpdateDialog.test.tsx b/apps/web/tests/components/UpdateDialog.test.tsx index 724aedddbf..25e0a5fbc7 100644 --- a/apps/web/tests/components/UpdateDialog.test.tsx +++ b/apps/web/tests/components/UpdateDialog.test.tsx @@ -109,6 +109,48 @@ describe('UpdateDialog', () => { expect(screen.getByRole('button', { name: 'Explore new features' })).toBeTruthy(); }); + it('shows reinstall copy and the operator link when the feed forces the installer route', async () => { + let openDialogListener: OpenDesignHostUpdaterOpenDialogListener | null = null; + const ready = payloadReadyStatus({ + artifact: { + name: 'open-design-1.2.4-setup.exe', + platformKey: 'win', + type: 'installer', + url: 'https://example.test/open-design-1.2.4-setup.exe', + }, + reinstall: { + installedVersion: '1.0.0', + minVersion: '1.2.0', + reason: 'outer-below-min', + url: 'https://example.com/reinstall-help', + }, + }); + restoreHost = installMockOpenDesignHost({ + host: { + updater: { + status: vi.fn(async () => ready), + subscribeOpenDialog: vi.fn((listener) => { + openDialogListener = listener; + return vi.fn(); + }), + }, + }, + }); + + render(); + await act(async () => { + openDialogListener?.({ source: 'mac-app-menu' }); + await Promise.resolve(); + }); + + await screen.findByRole('dialog', { name: 'Check for updates' }); + expect( + screen.getByText('Open Design 1.2.4 requires a full reinstall. Open Design will close and open the installer.'), + ).toBeTruthy(); + expect(screen.getByTestId('update-dialog-reinstall-learn-more')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Explore new features' })).toBeNull(); + }); + it('starts an explicit auto-downloading check when opened from an idle menu state', async () => { let openDialogListener: OpenDesignHostUpdaterOpenDialogListener | null = null; const check = vi.fn(async () => idleStatus({ state: 'not-available' })); diff --git a/apps/web/tests/components/UpdaterPopup.test.tsx b/apps/web/tests/components/UpdaterPopup.test.tsx index fad30a5382..282c659ba3 100644 --- a/apps/web/tests/components/UpdaterPopup.test.tsx +++ b/apps/web/tests/components/UpdaterPopup.test.tsx @@ -134,6 +134,55 @@ describe('UpdaterPopup', () => { expect(screen.queryByRole('button', { name: 'Collapse' })).toBeNull(); }); + it('uses reinstall copy and the learn-more link for forced installer reinstalls', async () => { + restoreHost = installMockOpenDesignHost({ + host: { + updater: { + status: vi.fn(async () => downloadedStatus({ + reinstall: { + installedVersion: '1.0.0-beta.9', + minVersion: '1.2.0-beta.1', + reason: 'outer-below-min', + url: 'https://example.com/reinstall-help', + }, + })), + }, + }, + }); + + render(); + + fireEvent.click(await screen.findByTestId('entry-nav-updater')); + + await screen.findByRole('dialog', { name: 'Update ready' }); + expect( + screen.getByText('Open Design 1.2.3-beta.4 requires a full reinstall. Open Design will close and open the installer.'), + ).toBeTruthy(); + expect(screen.getByTestId('updater-reinstall-learn-more')).toBeTruthy(); + }); + + it('omits the learn-more link when the reinstall requirement carries no url', async () => { + restoreHost = installMockOpenDesignHost({ + host: { + updater: { + status: vi.fn(async () => downloadedStatus({ + reinstall: { minVersion: '1.2.0-beta.1', reason: 'outer-version-unreadable' }, + })), + }, + }, + }); + + render(); + + fireEvent.click(await screen.findByTestId('entry-nav-updater')); + + await screen.findByRole('dialog', { name: 'Update ready' }); + expect( + screen.getByText('Open Design 1.2.3-beta.4 requires a full reinstall. Open Design will close and open the installer.'), + ).toBeTruthy(); + expect(screen.queryByTestId('updater-reinstall-learn-more')).toBeNull(); + }); + it('uses localized ready prompt copy from the app i18n provider', async () => { restoreHost = installMockOpenDesignHost({ host: { diff --git a/apps/web/tests/lib/updater.test.ts b/apps/web/tests/lib/updater.test.ts index 2b279d2185..e858cc4444 100644 --- a/apps/web/tests/lib/updater.test.ts +++ b/apps/web/tests/lib/updater.test.ts @@ -88,6 +88,33 @@ describe('web updater model', () => { expect(model.promptKey).toContain('1.2.3-beta.4'); }); + it('exposes the reinstall requirement from the host snapshot', () => { + const model = deriveUpdaterModel( + downloadedStatus({ + reinstall: { + installedVersion: '1.0.0-beta.9', + minVersion: '1.2.0-beta.1', + reason: 'outer-below-min', + url: 'https://example.com/reinstall-help', + }, + }), + { hostAvailable: true }, + ); + expect(model.reinstall).toEqual({ + installedVersion: '1.0.0-beta.9', + minVersion: '1.2.0-beta.1', + reason: 'outer-below-min', + url: 'https://example.com/reinstall-help', + }); + expect(model.updateKind).toBe('installer'); + expect(model.shouldPrompt).toBe(true); + }); + + it('defaults reinstall to null when the snapshot carries none', () => { + expect(deriveUpdaterModel(downloadedStatus(), { hostAvailable: true }).reinstall).toBeNull(); + expect(deriveUpdaterModel(null, { hostAvailable: false }).reinstall).toBeNull(); + }); + it('derives a desktop prompt for payload updates without manual installer capability', () => { const model = deriveUpdaterModel(payloadDownloadedStatus(), { hostAvailable: true }); expect(model.environment).toBe('desktop'); diff --git a/docs/testing/e2e-coverage/status.md b/docs/testing/e2e-coverage/status.md index 5a45f3f29e..d10b965bcf 100644 --- a/docs/testing/e2e-coverage/status.md +++ b/docs/testing/e2e-coverage/status.md @@ -2,6 +2,9 @@ 这份文档记录 `e2e/` 当前的自动化测试分层、最近一轮补强工作的落点,以及我们有意保留的已知缺口。 +安装与更新器全生命周期的「节点 → 归属测试」覆盖图谱单独维护在 +[`../updater-lifecycle.md`](../updater-lifecycle.md);改动 updater 相关代码时以那份图谱定位归属测试。 + ## 当前套件形态 现在这套 E2E 已经比较明确地分成三层: diff --git a/docs/testing/updater-lifecycle.md b/docs/testing/updater-lifecycle.md new file mode 100644 index 0000000000..c258ff018d --- /dev/null +++ b/docs/testing/updater-lifecycle.md @@ -0,0 +1,94 @@ +# Install + updater lifecycle coverage map + +The single reference for where every install/updater lifecycle node is tested. +When an updater change touches a node, run its owning tests (plus the matrix +in `tools/pack/AGENTS.md` → "Validation matrix for updater changes") and update +this map if ownership moves. Nodes marked **manual-only** are deliberate; do +not silently automate or drop them without revisiting the rationale here. + +Legend — **U**: unit/component (package `tests/`), **P**: packaged platform +spec (`e2e/specs/mac.spec.ts` / `win.spec.ts` via `release-smoke.ts`), +**F**: real-feed validated, **M**: manual-only. + +## A. Acquisition and first install + +| Node | Coverage | Owning tests | +| --- | --- | --- | +| Installer download + sha256 from the release feed | P, F | mac/win spec download flow; desktop `updater.test.ts` checksum trio | +| mac DMG install (ditto path) | P | mac spec install/start/inspect/stop/uninstall | +| mac DMG drag-install UI | M | human acceptance; harness uses ditto | +| win NSIS silent install + transactional log contract | P | win spec `assertTransactionalInPlaceInstallLog` | +| win NSIS interactive UI install | M | human acceptance per `tools/pack/AGENTS.md` | +| Channel identity (bundle name, registry key, install dir) | U, P | tools-pack `win-identity.test.ts`; specs | +| First-boot bootstrap (current-package, runtime.json gen0, install.json) | U, P | packaged `launcher-runtime.test.ts`; specs | +| Onboarding first run | P | mac/win onboarding smoke (`@electron-smoke`) | + +## B. Steady-state check loop + +| Node | Coverage | Owning tests | +| --- | --- | --- | +| Scheduler cadence / backoff / stop-after-install | U | desktop `updater.test.ts` scheduler group | +| Metadata fetch/parse/channel match, per-channel version fields | U, P, F | desktop unit; specs; real beta feed loop | +| not-available / available / downloaded-stays-visible | U, P | desktop unit; specs | +| Silent startup payload update (allowSilentUpdates) | U, P | desktop unit silent group; mac/win spec `applies a downloaded payload silently on the next cold start` | +| Artifact selection (payload vs installer, context validity) | U, P | desktop unit routing group; specs | +| Installer-reinstall floor (`control.launcher.version.min`): three reasons, same-version offer, clamp | U, P | desktop unit reseed group; spec recovery segment | +| Installed-outer version read (bundle config, env override) | U, P | desktop unit `resolveInstalledOuterVersion`; spec recovery segment reads the real outer | + +## C. Download and integrity + +| Node | Coverage | Owning tests | +| --- | --- | --- | +| Triple checksum verify (downloader, post-download, pre-install) | U | desktop unit | +| In-session resume; cross-session interrupted-download clearing | U | desktop unit (fixture byte ranges) | +| On-disk release adoption; store ownership/shape validation | U, P | desktop unit; specs | + +## D. Payload apply chain + +| Node | Coverage | Owning tests | +| --- | --- | --- | +| activate (generation++) → after-quit takeover → confirm | U, P, F | packaged + desktop unit; specs; real-feed loop | +| Delegated pre-arm (`--od-launcher-delegated-*`) | U | launcher-proto selection; packaged delegation/launch tests; desktop activation test | +| Crash rollback to lastSuccessful + self-heal on next release | P | mac/win spec `rolls back a crashing payload and self-heals on the next good update` | +| Stale relaunch freeze scrub after rollback (installResult.activeVersion > running) | U, P | desktop unit stale-freeze spec; exercised by the spec's self-heal phase | +| Exact desktop identity, cold-start reconvergence | P, F | specs; real-feed loop | +| Historical-outer handoff bridge (prepared→armed→confirmed) | U, partial P | daemon handoff unit; win spec legacy-executable path | +| Full historical-outer migration with a real legacy binary | M | needs a genuinely old installed generation | +| Obsolete outer retirement (mac/win) | U | packaged `obsolete-installed-outer.test.ts` | +| Reinstalled newer outer resets runtime (bound > active) | U | packaged `launcher-runtime.test.ts` supersede case | +| Reinstalled older outer delegates (bound < active) | P | spec recovery segment precondition | + +## E. Installer (reinstall) path + +| Node | Coverage | Owning tests | +| --- | --- | --- | +| win NSIS reinstall over the same registry key + transaction contract | P | win spec installer fallback acceptance | +| mac dry-run installer open | P | mac spec recovery segment | +| mac real DMG open via the deferred helper script | U, M | desktop unit fake-spawn; human acceptance | + +## F. Manual clear-cache (disaster recovery) + +| Node | Coverage | Owning tests | +| --- | --- | --- | +| Action chain (sidecar action → IPC → host → web) | U, P | boundary tests; web tests; spec recovery segment | +| Reset depth + retained-version protection | U, P | desktop unit clear-cache group; specs | +| Owned-but-corrupt store rebuild (sentinel = ownership proof) | U | desktop unit store-rebuild group | +| Post-clear recovery (re-check re-derives + re-downloads) | P | spec recovery segment | + +## G. Publication pipeline + +| Node | Coverage | Owning tests | +| --- | --- | --- | +| metadata.json composition + CAS latest publish | U | tools-serve `release-metadata-publish.test.ts` | +| Launcher version floor channel policy (vars pairs, stable fallback, validation) | U | tools-release `launcher-version-floor.test.ts`; publish integration | +| Workflow env passthrough (no YAML fallback) | U | tools-pack `release-workflows.test.ts` floor passthrough test | +| verify-metadata / summary-metadata wiring | U | publish integration test | +| Fixture bridge (control knobs, port pinning) | U | e2e `tests/updater-fixture.test.ts`; tools-serve fixture tests | + +## Known deliberate gaps + +- Interactive installer UIs (mac drag, NSIS wizard) stay human-verified. +- Full historical-outer migration needs a genuinely old installed build; the + daemon bridge is unit-tested and the win spec covers the legacy-executable + identity variant. +- Linux ships no packaged auto-update (AppImage manual) by design. diff --git a/e2e/lib/vitest/tools-serve-updater-fixture.ts b/e2e/lib/vitest/tools-serve-updater-fixture.ts index 09d6e2ccaf..53797e2739 100644 --- a/e2e/lib/vitest/tools-serve-updater-fixture.ts +++ b/e2e/lib/vitest/tools-serve-updater-fixture.ts @@ -21,8 +21,11 @@ export type ToolsServeUpdaterFixture = { export async function startToolsServeUpdaterFixture(options: { artifactPath?: string; channel: ReleaseChannel; + controlLauncherVersionMin?: string; + controlLauncherVersionUrl?: string; payloadPath?: string; platform: 'mac' | 'win'; + port?: number; version: string; workspaceRoot: string; }): Promise { @@ -41,7 +44,14 @@ export async function startToolsServeUpdaterFixture(options: { options.platform, ]; if (options.artifactPath != null) pnpmArgs.push('--artifact-path', options.artifactPath); + if (options.controlLauncherVersionMin != null) { + pnpmArgs.push('--control-launcher-version-min', options.controlLauncherVersionMin); + } + if (options.controlLauncherVersionUrl != null) { + pnpmArgs.push('--control-launcher-version-url', options.controlLauncherVersionUrl); + } if (options.payloadPath != null) pnpmArgs.push('--include-payload', '--payload-path', options.payloadPath); + if (options.port != null) pnpmArgs.push('--port', String(options.port)); const command = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'pnpm'; const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'pnpm.cmd', ...pnpmArgs] : pnpmArgs; const child = spawn(command, args, { diff --git a/e2e/specs/mac.spec.ts b/e2e/specs/mac.spec.ts index 31686f1e2e..7bf35e1637 100644 --- a/e2e/specs/mac.spec.ts +++ b/e2e/specs/mac.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { execFile } from 'node:child_process'; -import { access, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { access, chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -103,8 +103,10 @@ const updaterPopupExpression = ` (() => { const popup = document.querySelector('[data-testid="updater-popup"]'); const button = document.querySelector('[data-testid="updater-install-button"]'); + const reinstallLink = document.querySelector('[data-testid="updater-reinstall-learn-more"]'); return { installButtonVisible: button instanceof HTMLButtonElement && !button.disabled, + reinstallLinkVisible: reinstallLink instanceof HTMLElement, text: popup?.textContent?.trim() ?? null, title: popup?.querySelector('h2')?.textContent?.trim() ?? null, visible: popup instanceof HTMLElement, @@ -232,6 +234,12 @@ type MacInspectResult = { dryRun?: boolean; path: string; }; + reinstall?: { + installedVersion?: string; + minVersion?: string; + reason: string; + url?: string; + }; state: string; }; launcher: LauncherSnapshot; @@ -313,6 +321,7 @@ type PayloadRuntimeAcceptance = { type UpdaterPopupEvalValue = { installButtonVisible: boolean; + reinstallLinkVisible: boolean; text: string | null; title: string | null; visible: boolean; @@ -323,6 +332,14 @@ type UpdaterClickEvalValue = { reason?: string; }; +type UpdaterRecoverySummary = { + cleared: NonNullable; + downloadedBeforeClear: NonNullable; + dryRunInstall: MacInspectResult['update'] | null; + popup: UpdaterPopupEvalValue; + recovered: NonNullable; +}; + // The redesigned connect step exposes the two alternative runtimes as // secondary links on the cloud sign-in landing (AMR is the primary cloud CTA, // not a selectable link). @@ -357,14 +374,18 @@ macDescribe('packaged mac runtime smoke', () => { const report = await createPackagedSmokeReport('mac'); const updateEnv = captureUpdateEnv(); let payloadFixture: ToolsServeUpdaterFixture | null = null; + let recoveryFixture: ToolsServeUpdaterFixture | null = null; + let recoveryPayloadPath: string | null = null; let logs: LogsResult | { skipped: true } = { skipped: true }; let popup: UpdaterPopupEvalValue | { skipped: true } = { skipped: true }; let updateInstall: NonNullable | { skipped: true } = { skipped: true }; let updateStatus: NonNullable | { skipped: true } = { skipped: true }; let payloadRuntime: PayloadRuntimeAcceptance | { skipped: true } = { skipped: true }; + let updaterRecovery: UpdaterRecoverySummary | { skipped: true } = { skipped: true }; let upgradePersistence: UpgradePersistenceSeed | { skipped: true } = { skipped: true }; let passed = false; try { + await resetPackagedRuntimeState(); const install = await runToolsPackJson('install'); installedAppPath = install.installedAppPath; @@ -384,6 +405,7 @@ macDescribe('packaged mac runtime smoke', () => { assertToolsServeFixtureEnabled('mac', updateFixture); const localPayload = await resolveLocalPayloadUpdateFixture(); expectedPayloadUpdateVersion = localPayload.targetVersion; + recoveryPayloadPath = localPayload.payloadPath; payloadFixture = await startToolsServeUpdaterFixture({ channel: updateScenario.channel, payloadPath: localPayload.payloadPath, @@ -551,6 +573,96 @@ macDescribe('packaged mac runtime smoke', () => { identity, pptx, }; + + // Same-version reinstall + clear-cache recovery (mirrors the Windows + // lane's runSameVersionUpdaterRecoveryAcceptance): the physical outer + // is still the base install while the running payload is already at + // the target version, so only an installed-outer-aware floor can + // offer this installer reinstall. macOS has no silent DMG install to + // execute, so the installer open is asserted in dry-run mode instead + // of the Windows NSIS transaction. + if (recoveryPayloadPath != null) { + await payloadFixture?.close().catch((error: unknown) => { + console.error('failed to close payload update fixture before recovery', error); + }); + payloadFixture = null; + recoveryFixture = await startToolsServeUpdaterFixture({ + channel: updateScenario.channel, + controlLauncherVersionMin: updaterVersion, + controlLauncherVersionUrl: 'https://example.test/updater-recovery', + payloadPath: recoveryPayloadPath, + platform: 'mac', + version: updaterVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, recoveryFixture.info.metadataUrl, { openDryRun: true }); + + const recoveryStop = await runToolsPackJson('stop'); + started = false; + expect(recoveryStop.status).not.toBe('partial'); + const recoveryStart = await runToolsPackJson('start'); + started = true; + expect(recoveryStart.source).toBe('installed'); + await waitForHealthyDesktopVersion(updaterVersion, coldIdentity.pid); + + const reinstallReady = await waitForUpdaterStatus( + (inspect) => + inspect.update?.state === 'downloaded' && + inspect.update.artifact?.type === 'dmg' && + inspect.update.availableVersion === updaterVersion, + 'same-version reinstall downloaded', + ); + if (reinstallReady.update == null) throw new Error('same-version reinstall did not return updater status'); + expect(reinstallReady.update.currentVersion).toBe(updaterVersion); + expect(reinstallReady.update.reinstall).toEqual({ + installedVersion: updateScenario.expectedCurrentVersion, + minVersion: updaterVersion, + reason: 'outer-below-min', + url: 'https://example.test/updater-recovery', + }); + + const reinstallPopup = await openReadyUpdaterPrompt(updaterVersion); + expect(reinstallPopup.visible).toBe(true); + expect(reinstallPopup.installButtonVisible).toBe(true); + expect(reinstallPopup.reinstallLinkVisible).toBe(true); + + const clearedInspect = await runToolsPackJson('inspect', ['--update-action', 'clear-cache']); + if (clearedInspect.update == null) throw new Error('clear-cache did not return updater status'); + expect(clearedInspect.update.state).toBe('idle'); + expect(clearedInspect.update.downloadPath).toBeUndefined(); + expect(clearedInspect.update.installResult).toBeUndefined(); + expect(clearedInspect.update.reinstall).toBeUndefined(); + // Retained launcher versions must survive a manual clear. + expect(clearedInspect.launcher.active).toEqual(reinstallReady.launcher.active); + expect(clearedInspect.launcher.lastSuccessful).toEqual(reinstallReady.launcher.lastSuccessful); + + // Recovery: an explicit re-check re-derives the reinstall offer and + // re-downloads the installer artifact from the clean slate. + await runToolsPackJson('inspect', ['--update-action', 'check']); + const recovered = await waitForUpdaterStatus( + (inspect) => + inspect.update?.state === 'downloaded' && + inspect.update.artifact?.type === 'dmg' && + inspect.update.reinstall != null, + 'post-clear reinstall recovery', + ); + if (recovered.update == null) throw new Error('post-clear recovery did not return updater status'); + + const dryRunInstall = await runToolsPackJson('inspect', ['--update-action', 'install']); + expect(dryRunInstall.update?.installResult?.dryRun).toBe(true); + + // Leave a pristine updater behind for the final stop/uninstall. + const resetInspect = await runToolsPackJson('inspect', ['--update-action', 'clear-cache']); + expect(resetInspect.update?.state).toBe('idle'); + + updaterRecovery = { + cleared: clearedInspect.update, + downloadedBeforeClear: reinstallReady.update, + dryRunInstall: dryRunInstall.update ?? null, + popup: reinstallPopup, + recovered: recovered.update, + }; + } } await mkdir(dirname(screenshotPath), { recursive: true }); @@ -603,6 +715,7 @@ macDescribe('packaged mac runtime smoke', () => { status: updateStatus, install: updateInstall, }, + updaterRecovery, upgradePersistence, }); passed = true; @@ -611,6 +724,9 @@ macDescribe('packaged mac runtime smoke', () => { await payloadFixture?.close().catch((error: unknown) => { console.error('failed to close payload update fixture', error); }); + await recoveryFixture?.close().catch((error: unknown) => { + console.error('failed to close updater recovery fixture', error); + }); if (!passed) { await printPackagedLogs().catch((error: unknown) => { console.error('failed to read packaged mac logs after failure', error); @@ -625,7 +741,252 @@ macDescribe('packaged mac runtime smoke', () => { installedAppPath = null; } } - }, 240_000); + }, 360_000); + + // Silent startup update acceptance: with the daemon-owned allowSilentUpdates + // preference on, a payload downloaded in a previous session must apply on + // the next cold start's first scheduler tick — install, quit, and relaunch — + // without any user-facing updater action. + const silentUpdateTest = !verifyCoreOnly && updateFixture === 'tools-serve' ? test : test.skip; + silentUpdateTest('applies a downloaded payload silently on the next cold start', async () => { + const updateEnv = captureUpdateEnv(); + let payloadFixtureLocal: ToolsServeUpdaterFixture | null = null; + let cleanupStarted = false; + let cleanupInstalled = false; + try { + const localPayload = await resolveLocalPayloadUpdateFixture(); + const targetVersion = localPayload.targetVersion; + + await resetPackagedRuntimeState(); + await runToolsPackJson('install'); + cleanupInstalled = true; + await seedPackagedOnboardingComplete(); + + payloadFixtureLocal = await startToolsServeUpdaterFixture({ + channel: updateScenario.channel, + payloadPath: localPayload.payloadPath, + platform: 'mac', + version: targetVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, payloadFixtureLocal.info.metadataUrl, { openDryRun: false }); + + const start = await runToolsPackJson('start'); + cleanupStarted = true; + expect(start.source).toBe('installed'); + await waitForUpdaterStatus( + (status) => + status.update?.state === 'downloaded' && + status.update.availableVersion === targetVersion && + status.update.artifact?.type === 'payload', + 'payload downloaded before silent restart', + ); + + // Enable the daemon-owned preference through the production HTTP path + // (the same GET + merged PUT the web settings surface performs). + const enableSilent = await runToolsPackJson('inspect', ['--expr', ` + (async () => { + const current = await (await fetch('/api/app-config')).json(); + const response = await fetch('/api/app-config', { + headers: { 'content-type': 'application/json' }, + method: 'PUT', + body: JSON.stringify({ ...(current.config ?? {}), allowSilentUpdates: true }), + }); + const written = await response.json(); + return { ok: response.ok, allowSilentUpdates: written.config?.allowSilentUpdates }; + })() + `]); + expect(enableSilent.eval?.value).toEqual({ allowSilentUpdates: true, ok: true }); + + const stop = await runToolsPackJson('stop'); + cleanupStarted = false; + expect(stop.status).not.toBe('partial'); + + // Cold start: the first scheduler tick applies the already-downloaded + // payload silently and relaunches; no updater action is issued here. + const coldStart = await runToolsPackJson('start'); + cleanupStarted = true; + expect(coldStart.source).toBe('installed'); + const silent = await waitForHealthyDesktopVersion(targetVersion, start.pid); + const silentHealth = assertHealthEvalValue(silent.eval?.value); + expect(silentHealth.health.version).toBe(targetVersion); + const silentGeneration = settledLauncherGeneration(silent.launcher, targetVersion); + expect(silentGeneration).not.toBeNull(); + expect(silent.launcher.active?.version).toBe(targetVersion); + expect(silent.launcher.lastSuccessful?.version).toBe(targetVersion); + expect(silent.launcher.attempt).toBeNull(); + + const terminal = await waitForUpdaterStatus( + (status) => status.update?.state === 'not-available' && status.update.currentVersion === targetVersion, + 'silent update terminal state', + ); + expect(terminal.update?.currentVersion).toBe(targetVersion); + } finally { + restoreUpdateEnv(updateEnv); + await payloadFixtureLocal?.close().catch((error: unknown) => { + console.error('failed to close silent update fixture', error); + }); + if (cleanupStarted) { + await runToolsPackJson('stop').catch((error: unknown) => { + console.error('failed to stop packaged mac app during silent-update cleanup', error); + }); + } + if (cleanupInstalled) { + await runToolsPackJson('uninstall').catch((error: unknown) => { + console.error('failed to uninstall packaged mac app during silent-update cleanup', error); + }); + } + } + }, 360_000); + + // Crash-rollback acceptance: a payload that spawns but dies before its own + // bookkeeping must leave the pre-armed attempt behind, and the next cold + // start must roll back to the last successful version instead of retrying + // the broken payload forever. A follow-up update with a healthy payload + // then self-heals to the target version. + const rollbackTest = !verifyCoreOnly && updateFixture === 'tools-serve' ? test : test.skip; + rollbackTest('rolls back a crashing payload and self-heals on the next good update', async () => { + const updateEnv = captureUpdateEnv(); + let corruptFixture: ToolsServeUpdaterFixture | null = null; + let goodFixture: ToolsServeUpdaterFixture | null = null; + const corruptWorkDir = join(toolsPackDir, 'corrupt-payload-fixture'); + let cleanupStarted = false; + let cleanupInstalled = false; + try { + const localPayload = await resolveLocalPayloadUpdateFixture(); + const targetVersion = localPayload.targetVersion; + const corruptPayloadPath = await buildCorruptedMacPayloadFixture(localPayload.payloadPath, corruptWorkDir); + + await resetPackagedRuntimeState(); + const install = await runToolsPackJson('install'); + cleanupInstalled = true; + await seedPackagedOnboardingComplete(); + + corruptFixture = await startToolsServeUpdaterFixture({ + channel: updateScenario.channel, + payloadPath: corruptPayloadPath, + platform: 'mac', + version: targetVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, corruptFixture.info.metadataUrl, { openDryRun: false }); + + const start = await runToolsPackJson('start'); + cleanupStarted = true; + expect(start.source).toBe('installed'); + + const readyUpdate = await waitForUpdaterStatus( + (status) => + status.update?.state === 'downloaded' && + status.update.availableVersion === targetVersion && + status.update.artifact?.type === 'payload', + 'corrupt payload downloaded', + ); + const launcherRuntimePath = readyUpdate.launcher.runtimePath; + const launcherAttemptsPath = readyUpdate.launcher.attemptsPath; + + const popup = await openReadyUpdaterPrompt(targetVersion); + expect(popup.installButtonVisible).toBe(true); + const clickInstall = await runToolsPackJson('inspect', ['--expr', clickUpdaterInstallExpression]); + expect(assertUpdaterClickEvalValue(clickInstall.eval?.value).clicked).toBe(true); + + // The app quits for the relaunch; the corrupted payload stub then exits + // before any launcher bookkeeping. Wait for the desktop to disappear. + await waitForDesktopGone('crashing payload never became the desktop'); + cleanupStarted = false; + + // The pre-armed attempt is the rollback evidence the crash left behind. + const strandedAttempt = JSON.parse(await readFile(launcherAttemptsPath, 'utf8')) as { + generation?: number; + version?: string; + }; + expect(strandedAttempt.version).toBe(targetVersion); + const strandedRuntime = JSON.parse(await readFile(launcherRuntimePath, 'utf8')) as { + active?: { generation?: number; version?: string }; + lastSuccessful?: { generation?: number; version?: string }; + }; + expect(strandedRuntime.active?.version).toBe(targetVersion); + expect(strandedRuntime.lastSuccessful?.version).toBe(updateScenario.expectedCurrentVersion); + expect(strandedAttempt.generation).toBe(strandedRuntime.active?.generation); + + // Cold start rolls back: the installed outer sees the unconfirmed + // attempt, selects lastSuccessful, and serves the base version again. + const rollbackStart = await runToolsPackJson('start'); + cleanupStarted = true; + expect(rollbackStart.source).toBe('installed'); + const rolledBack = await waitForHealthyDesktopVersion(updateScenario.expectedCurrentVersion, start.pid, false); + const rolledBackHealth = assertHealthEvalValue(rolledBack.eval?.value); + expect(rolledBackHealth.health.version).toBe(updateScenario.expectedCurrentVersion); + expect(rolledBack.launcher.lastSuccessful?.version).toBe(updateScenario.expectedCurrentVersion); + // Degraded steady state: the broken pointer stays active with its + // attempt as evidence until a healthy release replaces it. + expect(rolledBack.launcher.active?.version).toBe(targetVersion); + expect(rolledBack.launcher.attempt?.version).toBe(targetVersion); + + // Self-heal: real recovery releases ship as version+1 (versioned + // artifacts are immutable), so the next update arrives under a bumped + // version with a healthy payload and converges. + const healedVersion = bumpCountedVersion(targetVersion); + const healedPayloadPath = await buildVersionBumpedMacPayloadFixture( + localPayload.payloadPath, + corruptWorkDir, + healedVersion, + ); + await corruptFixture.close(); + corruptFixture = null; + goodFixture = await startToolsServeUpdaterFixture({ + channel: updateScenario.channel, + payloadPath: healedPayloadPath, + platform: 'mac', + version: healedVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, goodFixture.info.metadataUrl, { openDryRun: false }); + const healStop = await runToolsPackJson('stop'); + cleanupStarted = false; + expect(healStop.status).not.toBe('partial'); + const healStart = await runToolsPackJson('start'); + cleanupStarted = true; + expect(healStart.source).toBe('installed'); + await waitForUpdaterStatus( + (status) => + status.update?.state === 'downloaded' && + status.update.availableVersion === healedVersion && + status.update.artifact?.type === 'payload', + 'healthy payload downloaded after rollback', + ); + await openReadyUpdaterPrompt(healedVersion); + const healClick = await runToolsPackJson('inspect', ['--expr', clickUpdaterInstallExpression]); + expect(assertUpdaterClickEvalValue(healClick.eval?.value).clicked).toBe(true); + const healed = await waitForHealthyDesktopVersion(healedVersion, rollbackStart.pid); + const healedHealth = assertHealthEvalValue(healed.eval?.value); + expect(healedHealth.health.version).toBe(healedVersion); + const healedGeneration = settledLauncherGeneration(healed.launcher, healedVersion); + expect(healedGeneration).not.toBeNull(); + expect(healed.launcher.active?.version).toBe(healedVersion); + expect(healed.launcher.lastSuccessful?.version).toBe(healedVersion); + expect(healed.launcher.attempt).toBeNull(); + } finally { + restoreUpdateEnv(updateEnv); + await corruptFixture?.close().catch((error: unknown) => { + console.error('failed to close corrupt payload fixture', error); + }); + await goodFixture?.close().catch((error: unknown) => { + console.error('failed to close healthy payload fixture', error); + }); + await rm(corruptWorkDir, { force: true, recursive: true }).catch(() => undefined); + if (cleanupStarted) { + await runToolsPackJson('stop').catch((error: unknown) => { + console.error('failed to stop packaged mac app during rollback cleanup', error); + }); + } + if (cleanupInstalled) { + await runToolsPackJson('uninstall').catch((error: unknown) => { + console.error('failed to uninstall packaged mac app during rollback cleanup', error); + }); + } + } + }, 360_000); }); macOnboardingDescribe('packaged mac onboarding AMR smoke', () => { @@ -2093,7 +2454,14 @@ async function waitForHealthyDesktop(): Promise { throw new Error(`packaged mac runtime did not become healthy: ${formatUnknown(lastResult)}`); } -async function waitForHealthyDesktopVersion(expectedVersion: string, previousPid: number | null | undefined): Promise { +async function waitForHealthyDesktopVersion( + expectedVersion: string, + previousPid: number | null | undefined, + // The rollback degraded steady state deliberately keeps the broken pointer + // active (with its attempt as evidence), so callers waiting on a rolled-back + // desktop must not require settled launcher pointers. + requireSettledLauncher = true, +): Promise { const timeoutMs = 120_000; const startedAt = Date.now(); let lastResult: unknown = null; @@ -2109,7 +2477,7 @@ async function waitForHealthyDesktopVersion(expectedVersion: string, previousPid value.health.ok === true && value.health.version === expectedVersion && (previousPid == null || inspect.status.pid !== previousPid) && - settledLauncherGeneration(inspect.launcher, expectedVersion) != null + (!requireSettledLauncher || settledLauncherGeneration(inspect.launcher, expectedVersion) != null) ) { return inspect; } @@ -2184,6 +2552,114 @@ async function waitForUpdaterStatus( throw new Error(`${label}: updater status timed out: ${formatUnknown(lastResult)}`); } +async function repackMacPayloadFixture( + payloadZipPath: string, + workDir: string, + outputName: string, + mutate: (extractRoot: string, manifest: { entry?: { executable?: string }; version?: string }) => Promise, +): Promise { + const extractRoot = join(workDir, `${outputName}-extract`); + await rm(extractRoot, { force: true, recursive: true }); + await mkdir(extractRoot, { recursive: true }); + await execFileAsync('ditto', ['-x', '-k', payloadZipPath, extractRoot]); + const manifestPath = join(extractRoot, 'manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + entry?: { executable?: string }; + version?: string; + }; + await mutate(extractRoot, manifest); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + const zipPath = join(workDir, `${outputName}.zip`); + await rm(zipPath, { force: true }); + await execFileAsync('ditto', ['-c', '-k', '--sequesterRsrc', '--rsrc', extractRoot, zipPath]); + return zipPath; +} + +/** + * Build a checksum-valid payload zip whose desktop executable dies before any + * launcher bookkeeping — the faithful shape of a broken release that passes + * every integrity gate (zip sha256, manifest validation, activation) and then + * crashes pre-main. + */ +async function buildCorruptedMacPayloadFixture(payloadZipPath: string, workDir: string): Promise { + return await repackMacPayloadFixture(payloadZipPath, workDir, 'corrupt-payload', async (extractRoot, manifest) => { + const executableRelPath = manifest.entry?.executable; + if (executableRelPath == null || executableRelPath.length === 0) { + throw new Error(`payload manifest has no entry.executable: ${payloadZipPath}`); + } + const executablePath = join(extractRoot, executableRelPath); + await writeFile(executablePath, '#!/bin/sh\nexit 87\n', 'utf8'); + await chmod(executablePath, 0o755); + }); +} + +/** + * Re-version a healthy payload zip to the next counted release. Real recovery + * releases ship as version+1 (versioned artifacts are immutable), so the + * self-heal update must arrive under a bumped version rather than overwriting + * the broken pointer's version root. The desktop binary is unchanged — the + * running version is config/manifest-driven. + */ +async function buildVersionBumpedMacPayloadFixture( + payloadZipPath: string, + workDir: string, + bumpedVersion: string, +): Promise { + return await repackMacPayloadFixture(payloadZipPath, workDir, 'healed-payload', async (extractRoot, manifest) => { + manifest.version = bumpedVersion; + const executableRelPath = manifest.entry?.executable; + if (executableRelPath == null || executableRelPath.length === 0) { + throw new Error(`payload manifest has no entry.executable: ${payloadZipPath}`); + } + // .app/Contents/MacOS/ → .app/Contents/Resources + const configPath = join(extractRoot, dirname(dirname(executableRelPath)), 'Resources', 'open-design-config.json'); + const config = JSON.parse(await readFile(configPath, 'utf8')) as { appVersion?: string }; + config.appVersion = bumpedVersion; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + }); +} + +function bumpCountedVersion(version: string): string { + const match = /^(.*[.-](?:beta|betas|prerelease|preview))\.(\d+)$/.exec(version); + if (match?.[1] == null || match[2] == null) { + throw new Error(`rollback acceptance requires a counted version to bump: ${version}`); + } + return `${match[1]}.${Number(match[2]) + 1}`; +} + +/** + * Reset the namespace to a pristine pre-install state. `uninstall` removes the + * installed app but deliberately keeps runtime data; lifecycle tests must not + * inherit the previous test's (or a previous local run's) launcher pointers, + * update store, or daemon preferences, so each test starts from zero. + */ +async function resetPackagedRuntimeState(): Promise { + await runToolsPackJson('stop').catch(() => undefined); + await runToolsPackJson('uninstall').catch(() => undefined); + await rm(runtimeNamespaceRoot, { force: true, recursive: true }).catch(() => undefined); + await rm( + join(toolsPackDir, 'runtime', 'mac', 'launcher', 'channels', updateScenario.channel, 'namespaces', namespace), + { force: true, recursive: true }, + ).catch(() => undefined); +} + +async function waitForDesktopGone(label: string, timeoutMs = 120_000): Promise { + const startedAt = Date.now(); + let lastResult: unknown = null; + while (Date.now() - startedAt < timeoutMs) { + try { + const inspect = await runToolsPackJson('inspect'); + lastResult = inspect; + if (inspect.status == null || inspect.status.state !== 'running') return; + } catch { + // A dead desktop IPC socket is exactly the expected terminal state. + return; + } + await delay(1000); + } + throw new Error(`${label}: desktop still running: ${formatUnknown(lastResult)}`); +} + async function openReadyUpdaterPrompt(version: string): Promise { await clickUpdaterRailButton('open ready updater prompt'); return await waitForUpdaterPopupMatching( @@ -2445,6 +2921,7 @@ function asUpdaterPopupEvalValue(value: unknown): UpdaterPopupEvalValue | null { if (!isRecord(value)) return null; if (typeof value.visible !== 'boolean') return null; if (typeof value.installButtonVisible !== 'boolean') return null; + if (typeof value.reinstallLinkVisible !== 'boolean') return null; if (value.title != null && typeof value.title !== 'string') return null; if (value.text != null && typeof value.text !== 'string') return null; return value as UpdaterPopupEvalValue; diff --git a/e2e/specs/win.spec.ts b/e2e/specs/win.spec.ts index adc7921921..8f82daae01 100644 --- a/e2e/specs/win.spec.ts +++ b/e2e/specs/win.spec.ts @@ -3,7 +3,7 @@ import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -34,7 +34,11 @@ const verifyUpgradePersistence = const updateMetadataUrl = normalizeOptionalEnv(process.env.OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL); const updateVersion = normalizeOptionalEnv(process.env.OD_PACKAGED_E2E_WIN_UPDATE_VERSION); const updateBuildJsonPath = normalizeOptionalEnv(process.env.OD_PACKAGED_E2E_WIN_UPDATE_BUILD_JSON_PATH); +const intermediateUpdateBuildJsonPath = normalizeOptionalEnv( + process.env.OD_PACKAGED_E2E_WIN_INTERMEDIATE_UPDATE_BUILD_JSON_PATH, +); const updateFixture = normalizeOptionalEnv(process.env.OD_PACKAGED_E2E_WIN_UPDATE_FIXTURE); +const updateFixturePort = resolveOptionalFixturePort(process.env.OD_PACKAGED_E2E_WIN_UPDATE_FIXTURE_PORT); const updateFixtureMode = resolveUpdateFixtureMode(process.env.OD_PACKAGED_E2E_WIN_UPDATE_MODE); const releaseChannel = process.env.OD_PACKAGED_E2E_RELEASE_CHANNEL; const releaseVersion = process.env.OD_PACKAGED_E2E_RELEASE_VERSION; @@ -43,6 +47,16 @@ const installIdentity = resolvePackagedWinInstallIdentity({ namespace, releaseVe const outputNamespaceRoot = join(toolsPackDir, 'out', 'win', 'namespaces', namespace); const runtimeNamespaceRoot = join(toolsPackDir, 'runtime', 'win', 'namespaces', namespace); +const launcherNamespaceRoot = join( + toolsPackDir, + 'runtime', + 'win', + 'launcher', + 'channels', + updateScenario.channel, + 'namespaces', + namespace, +); const screenshotPath = join(toolsPackDir, 'screenshots', `${namespace}.png`); const preUpdateScreenshotPath = join(toolsPackDir, 'screenshots', `${namespace}-before-update.png`); const readinessExpression = ` @@ -165,8 +179,10 @@ const updaterPopupExpression = ` (() => { const popup = document.querySelector('[data-testid="updater-popup"]'); const button = document.querySelector('[data-testid="updater-install-button"]'); + const reinstallLink = document.querySelector('[data-testid="updater-reinstall-learn-more"]'); return { installButtonVisible: button instanceof HTMLButtonElement && !button.disabled, + reinstallLinkVisible: reinstallLink instanceof HTMLElement, text: popup?.textContent?.trim() ?? null, title: popup?.querySelector('h2')?.textContent?.trim() ?? null, visible: popup instanceof HTMLElement, @@ -379,6 +395,12 @@ type WinInspectResult = { receivedBytes?: number; totalBytes?: number; }; + reinstall?: { + installedVersion?: string; + minVersion?: string; + reason: string; + url?: string; + }; state: string; }; webStatus: DesktopStatus | null; @@ -456,6 +478,7 @@ type DesktopIdentityMarker = { type UpdaterPopupEvalValue = { installButtonVisible: boolean; + reinstallLinkVisible: boolean; text: string | null; title: string | null; visible: boolean; @@ -510,13 +533,17 @@ winDescribe('packaged windows runtime smoke', () => { const report = await createPackagedSmokeReport('win'); let passed = false; const timings: SmokeTiming[] = []; + let intermediatePayloadUpdate: PayloadUpdateSummary | { skipped: true } = { skipped: true }; let payloadUpdate: InstallerFallbackSummary | PayloadUpdateSummary | { skipped: true } = { skipped: true }; + let updaterRecovery: UpdaterRecoverySummary | { skipped: true } = { skipped: true }; let reinstall: DirectInstallerResult | { skipped: true } = { skipped: true }; let logs: LogsResult | { skipped: true } = { skipped: true }; let stop: WinStopResult | { skipped: true } = { skipped: true }; let postUpdateHealth: HealthEvalValue | { skipped: true } = { skipped: true }; let upgradePersistence: UpgradePersistenceSeed | { skipped: true } = { skipped: true }; let payloadFixture: ToolsServeUpdaterFixture | null = null; + let intermediateUpdateFixture: Awaited> | null = null; + let localUpdateFixture: Awaited> | null = null; const updateEnv = captureUpdateEnv(); try { if (!verifyCoreOnly && updateScenario.channel === 'beta') { @@ -524,6 +551,7 @@ winDescribe('packaged windows runtime smoke', () => { } await measureSmokeStep(timings, 'pre-clean uninstall', async () => { await runToolsPackJson('uninstall', ['--remove-product-user-data']).catch(() => null); + await resetPackagedUpdaterNamespaceRoots(); }); const install = await measureSmokeStep(timings, 'install', async () => runToolsPackJson('install')); @@ -571,14 +599,22 @@ winDescribe('packaged windows runtime smoke', () => { applyPackagedUpdateEnv(process.env, updateScenario, updateMetadataUrl, { openDryRun: false }); } else { assertToolsServeFixtureEnabled('Windows', updateFixture); - const localUpdate = await resolveLocalUpdateFixture(); - expectedPayloadUpdateVersion = localUpdate.targetVersion; + localUpdateFixture = await resolveLocalUpdateFixture(); + if (intermediateUpdateBuildJsonPath != null) { + if (updateFixtureMode !== 'payload') { + throw new Error('Windows intermediate updater recovery requires payload fixture mode'); + } + intermediateUpdateFixture = await resolveLocalUpdateFixture(intermediateUpdateBuildJsonPath); + } + const initialUpdateFixture = intermediateUpdateFixture ?? localUpdateFixture; + expectedPayloadUpdateVersion = initialUpdateFixture.targetVersion; payloadFixture = await startToolsServeUpdaterFixture({ - ...(updateFixtureMode === 'installer' ? { artifactPath: localUpdate.installerPath } : {}), + artifactPath: initialUpdateFixture.installerPath, channel: updateScenario.channel, - ...(updateFixtureMode === 'payload' ? { payloadPath: localUpdate.payloadPath } : {}), + ...(updateFixtureMode === 'payload' ? { payloadPath: initialUpdateFixture.payloadPath } : {}), platform: 'win', - version: localUpdate.targetVersion, + ...(updateFixturePort == null ? {} : { port: updateFixturePort }), + version: initialUpdateFixture.targetVersion, workspaceRoot, }); applyPackagedUpdateEnv(process.env, updateScenario, payloadFixture.info.metadataUrl, { openDryRun: false }); @@ -639,9 +675,97 @@ winDescribe('packaged windows runtime smoke', () => { installDir: install.installDir, persistedProjectId, }) - : runPayloadUpdateAcceptance({ expectedVersion: expectedPayloadUpdateVersion, persistedProjectId }), + : runPayloadUpdateAcceptance({ + expectedVersion: expectedPayloadUpdateVersion, + ...(intermediateUpdateFixture == null + ? {} + : { legacyInstalledExecutablePath: join(install.installDir, 'Open Design.exe') }), + persistedProjectId, + verifyPptx: intermediateUpdateFixture == null, + }), ); postUpdateHealth = payloadUpdate.health; + + if (intermediateUpdateFixture != null && localUpdateFixture != null && payloadFixture != null) { + if ('skipped' in payloadUpdate || !('launcherAfterConfirm' in payloadUpdate)) { + throw new Error('Windows intermediate update did not complete through the payload path'); + } + const intermediateIdentityPid = payloadUpdate.identity.pid; + intermediatePayloadUpdate = payloadUpdate; + await payloadFixture.close(); + payloadFixture = await startToolsServeUpdaterFixture({ + artifactPath: localUpdateFixture.installerPath, + channel: updateScenario.channel, + payloadPath: localUpdateFixture.payloadPath, + platform: 'win', + ...(updateFixturePort == null ? {} : { port: updateFixturePort }), + version: localUpdateFixture.targetVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, payloadFixture.info.metadataUrl, { openDryRun: false }); + const intermediateVersion = intermediateUpdateFixture.targetVersion; + const targetVersion = localUpdateFixture.targetVersion; + process.env.OD_UPDATE_CURRENT_VERSION = intermediateVersion; + const fixtureSwitchStop = await measureSmokeStep(timings, 'stop before target update fixture', async () => + runToolsPackJson('stop'), + ); + started = false; + expect(fixtureSwitchStop.status).not.toBe('partial'); + expect(fixtureSwitchStop.remainingPids).toEqual([]); + start = await startDesktop('restart with target update fixture'); + expect(start.source).toBe('installed'); + await measureSmokeStep(timings, 'wait healthy after target fixture restart', async () => + waitForHealthyDesktopVersion(intermediateVersion, intermediateIdentityPid), + ); + expectedPayloadUpdateVersion = targetVersion; + payloadUpdate = await measureSmokeStep(timings, 'target payload update acceptance', async () => + runPayloadUpdateAcceptance({ + expectedCurrentVersion: intermediateVersion, + expectedVersion: targetVersion, + persistedProjectId, + }), + ); + postUpdateHealth = payloadUpdate.health; + } + + // A local full payload fixture has both artifacts, so reuse the exact + // target version with an installed-outer floor. The running payload is + // already at targetVersion while the physical outer is still the base + // install: only an outer-version-aware updater can offer this + // same-version installer reinstall. + if ( + updateFixtureMode === 'payload' && + localUpdateFixture != null && + payloadFixture != null && + expectedPayloadUpdateVersion != null + ) { + await payloadFixture.close(); + payloadFixture = await startToolsServeUpdaterFixture({ + artifactPath: localUpdateFixture.installerPath, + channel: updateScenario.channel, + controlLauncherVersionMin: expectedPayloadUpdateVersion, + controlLauncherVersionUrl: 'https://example.test/updater-recovery', + payloadPath: localUpdateFixture.payloadPath, + platform: 'win', + ...(updateFixturePort == null ? {} : { port: updateFixturePort }), + version: expectedPayloadUpdateVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, payloadFixture.info.metadataUrl, { openDryRun: false }); + process.env.OD_UPDATE_CURRENT_VERSION = expectedPayloadUpdateVersion; + const recoveryFixture = payloadFixture; + const recoveryTargetVersion = expectedPayloadUpdateVersion; + updaterRecovery = await measureSmokeStep(timings, 'same-version reinstall and clear-cache recovery', async () => + runSameVersionUpdaterRecoveryAcceptance({ + expectedInstalledVersion: updateScenario.expectedCurrentVersion, + fixture: recoveryFixture, + installDir: install.installDir, + persistedProjectId, + targetVersion: recoveryTargetVersion, + }), + ); + postUpdateHealth = updaterRecovery.installer.health; + } } if (verifyReinstallWhileRunning && verifyCoreOnly) { @@ -650,11 +774,7 @@ winDescribe('packaged windows runtime smoke', () => { ); started = false; expect(reinstall.code).toBe(0); - expect(reinstall.nsisLogTail.join('\n')).toContain('running instances detected before silent install'); - // The installer closes running instances via pwsh.exe, falling back to - // powershell.exe (#2799), so the log reads "running instances close via - // .exe exit=0" rather than the older "running instances close exit=0". - expect(reinstall.nsisLogTail.join('\n')).toMatch(/running instances close via (?:pwsh|powershell)\.exe exit=0/); + assertTransactionalInPlaceInstallLog(reinstall.nsisLogTail); start = await measureSmokeStep(timings, 'restart after direct reinstall', async () => runToolsPackJson('start'), @@ -719,9 +839,11 @@ winDescribe('packaged windows runtime smoke', () => { uninstallerPath: install.uninstallerPath, }, installTiming, + intermediatePayloadUpdate, logs: 'skipped' in logs ? logs : summarizeLogs(logs), namespace, payloadUpdate, + updaterRecovery, reinstall, screenshot: inspect.desktopIpcUnavailable ? null : report.screenshotRelpath, screenshots: inspect.desktopIpcUnavailable @@ -777,6 +899,235 @@ winDescribe('packaged windows runtime smoke', () => { printSmokeTimings(timings); } }, 720_000); + + // Silent startup update acceptance (mirror of the mac lane): with the + // daemon-owned allowSilentUpdates preference on, a payload downloaded in a + // previous session must apply on the next cold start's first scheduler tick + // without any user-facing updater action. + const silentUpdateTest = + !verifyCoreOnly && updateFixture === 'tools-serve' && updateFixtureMode === 'payload' ? test : test.skip; + silentUpdateTest('applies a downloaded payload silently on the next cold start', async () => { + const updateEnv = captureUpdateEnv(); + let payloadFixtureLocal: ToolsServeUpdaterFixture | null = null; + let cleanupStarted = false; + let cleanupInstalled = false; + try { + const localUpdate = await resolveLocalUpdateFixture(); + const targetVersion = localUpdate.targetVersion; + + await runToolsPackJson('uninstall', ['--remove-product-user-data']).catch(() => null); + await resetPackagedUpdaterNamespaceRoots(); + await runToolsPackJson('install'); + cleanupInstalled = true; + await seedPackagedOnboardingComplete(); + + payloadFixtureLocal = await startToolsServeUpdaterFixture({ + artifactPath: localUpdate.installerPath, + channel: updateScenario.channel, + payloadPath: localUpdate.payloadPath, + platform: 'win', + version: targetVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, payloadFixtureLocal.info.metadataUrl, { openDryRun: false }); + + const start = await runToolsPackJson('start'); + cleanupStarted = true; + expect(start.source).toBe('installed'); + await waitForDownloadedUpdater(targetVersion, 'payload'); + + // Enable the daemon-owned preference through the production HTTP path + // (the same GET + merged PUT the web settings surface performs). + const enableSilent = await runToolsPackJson('inspect', ['--expr', ` + (async () => { + const current = await (await fetch('/api/app-config')).json(); + const response = await fetch('/api/app-config', { + headers: { 'content-type': 'application/json' }, + method: 'PUT', + body: JSON.stringify({ ...(current.config ?? {}), allowSilentUpdates: true }), + }); + const written = await response.json(); + return { ok: response.ok, allowSilentUpdates: written.config?.allowSilentUpdates }; + })() + `]); + expect(enableSilent.eval?.value).toEqual({ allowSilentUpdates: true, ok: true }); + + const stop = await runToolsPackJson('stop'); + cleanupStarted = false; + expect(stop.status).not.toBe('partial'); + + // Cold start: the first scheduler tick applies the already-downloaded + // payload silently and relaunches; no updater action is issued here. + const coldStart = await runToolsPackJson('start'); + cleanupStarted = true; + expect(coldStart.source).toBe('installed'); + const silent = await waitForHealthyDesktopVersion(targetVersion, start.pid); + expect(settledLauncherGeneration(silent.launcher, targetVersion)).not.toBeNull(); + expect(silent.launcher.active?.version).toBe(targetVersion); + expect(silent.launcher.lastSuccessful?.version).toBe(targetVersion); + expect(silent.launcher.attempt).toBeNull(); + + const terminal = await waitForTerminalUpdateState(targetVersion); + expect(terminal.update?.currentVersion).toBe(targetVersion); + } finally { + restoreUpdateEnv(updateEnv); + await payloadFixtureLocal?.close().catch((error: unknown) => { + console.error('failed to close silent update fixture', error); + }); + if (cleanupStarted) { + await runToolsPackJson('stop').catch((error: unknown) => { + console.error('failed to stop packaged windows app during silent-update cleanup', error); + }); + } + if (cleanupInstalled) { + await runToolsPackJson('uninstall', ['--remove-product-user-data']).catch((error: unknown) => { + console.error('failed to uninstall packaged windows app during silent-update cleanup', error); + }); + } + } + }, 720_000); + + // Crash-rollback acceptance (mirror of the mac lane): a payload that spawns + // but dies before its own launcher bookkeeping must leave the pre-armed + // attempt behind; the next cold start rolls back to the last successful + // version, and a version-bumped healthy release self-heals. + const rollbackTest = + !verifyCoreOnly && updateFixture === 'tools-serve' && updateFixtureMode === 'payload' ? test : test.skip; + rollbackTest('rolls back a crashing payload and self-heals on the next good update', async () => { + const updateEnv = captureUpdateEnv(); + let corruptFixture: ToolsServeUpdaterFixture | null = null; + let goodFixture: ToolsServeUpdaterFixture | null = null; + const corruptWorkDir = join(toolsPackDir, 'corrupt-payload-fixture'); + let cleanupStarted = false; + let cleanupInstalled = false; + try { + const localUpdate = await resolveLocalUpdateFixture(); + const targetVersion = localUpdate.targetVersion; + + await runToolsPackJson('uninstall', ['--remove-product-user-data']).catch(() => null); + await resetPackagedUpdaterNamespaceRoots(); + const install = await runToolsPackJson('install'); + cleanupInstalled = true; + await seedPackagedOnboardingComplete(); + + const sevenZipExe = join(install.installDir, 'resources', 'open-design', 'bin', '7z.exe'); + expect((await stat(sevenZipExe)).isFile()).toBe(true); + const corruptPayloadPath = await buildCorruptedWinPayloadFixture( + localUpdate.payloadPath, + corruptWorkDir, + sevenZipExe, + ); + + corruptFixture = await startToolsServeUpdaterFixture({ + artifactPath: localUpdate.installerPath, + channel: updateScenario.channel, + payloadPath: corruptPayloadPath, + platform: 'win', + version: targetVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, corruptFixture.info.metadataUrl, { openDryRun: false }); + + const start = await runToolsPackJson('start'); + cleanupStarted = true; + expect(start.source).toBe('installed'); + const readyUpdate = await waitForDownloadedUpdater(targetVersion, 'payload'); + const launcherRuntimePath = readyUpdate.launcher.runtimePath; + const launcherAttemptsPath = readyUpdate.launcher.attemptsPath; + + const popup = await openReadyUpdaterPrompt(targetVersion); + expect(popup.installButtonVisible).toBe(true); + const clickInstall = await runToolsPackJson('inspect', ['--expr', clickUpdaterInstallExpression]); + expect(assertUpdaterClickEvalValue(clickInstall.eval?.value).clicked).toBe(true); + + // The app quits for the relaunch; the corrupted payload stub then exits + // before any launcher bookkeeping. Wait for the desktop to disappear. + await waitForDesktopGone('crashing payload never became the desktop'); + cleanupStarted = false; + + // The pre-armed attempt is the rollback evidence the crash left behind. + const strandedAttempt = JSON.parse(await readFile(launcherAttemptsPath, 'utf8')) as { + generation?: number; + version?: string; + }; + expect(strandedAttempt.version).toBe(targetVersion); + const strandedRuntime = JSON.parse(await readFile(launcherRuntimePath, 'utf8')) as { + active?: { generation?: number; version?: string }; + lastSuccessful?: { generation?: number; version?: string }; + }; + expect(strandedRuntime.active?.version).toBe(targetVersion); + expect(strandedRuntime.lastSuccessful?.version).toBe(updateScenario.expectedCurrentVersion); + expect(strandedAttempt.generation).toBe(strandedRuntime.active?.generation); + + // Cold start rolls back: the installed outer sees the unconfirmed + // attempt, selects lastSuccessful, and serves the base version again. + const rollbackStart = await runToolsPackJson('start'); + cleanupStarted = true; + expect(rollbackStart.source).toBe('installed'); + const rolledBack = await waitForHealthyDesktopVersion(updateScenario.expectedCurrentVersion, start.pid, false); + expect(rolledBack.launcher.lastSuccessful?.version).toBe(updateScenario.expectedCurrentVersion); + // Degraded steady state: the broken pointer stays active with its + // attempt as evidence until a healthy release replaces it. + expect(rolledBack.launcher.active?.version).toBe(targetVersion); + expect(rolledBack.launcher.attempt?.version).toBe(targetVersion); + + // Self-heal: real recovery releases ship as version+1 (versioned + // artifacts are immutable), so the next update arrives under a bumped + // version with a healthy payload and converges. + const healedVersion = bumpCountedVersion(targetVersion); + const healedPayloadPath = await buildVersionBumpedWinPayloadFixture( + localUpdate.payloadPath, + corruptWorkDir, + sevenZipExe, + healedVersion, + ); + await corruptFixture.close(); + corruptFixture = null; + goodFixture = await startToolsServeUpdaterFixture({ + artifactPath: localUpdate.installerPath, + channel: updateScenario.channel, + payloadPath: healedPayloadPath, + platform: 'win', + version: healedVersion, + workspaceRoot, + }); + applyPackagedUpdateEnv(process.env, updateScenario, goodFixture.info.metadataUrl, { openDryRun: false }); + const healStop = await runToolsPackJson('stop'); + cleanupStarted = false; + expect(healStop.status).not.toBe('partial'); + const healStart = await runToolsPackJson('start'); + cleanupStarted = true; + expect(healStart.source).toBe('installed'); + await waitForDownloadedUpdater(healedVersion, 'payload', 120_000, updateScenario.expectedCurrentVersion); + await openReadyUpdaterPrompt(healedVersion); + const healClick = await runToolsPackJson('inspect', ['--expr', clickUpdaterInstallExpression]); + expect(assertUpdaterClickEvalValue(healClick.eval?.value).clicked).toBe(true); + const healed = await waitForHealthyDesktopVersion(healedVersion, rollbackStart.pid); + expect(settledLauncherGeneration(healed.launcher, healedVersion)).not.toBeNull(); + expect(healed.launcher.active?.version).toBe(healedVersion); + expect(healed.launcher.lastSuccessful?.version).toBe(healedVersion); + expect(healed.launcher.attempt).toBeNull(); + } finally { + restoreUpdateEnv(updateEnv); + await corruptFixture?.close().catch((error: unknown) => { + console.error('failed to close corrupt payload fixture', error); + }); + await goodFixture?.close().catch((error: unknown) => { + console.error('failed to close healthy payload fixture', error); + }); + await rm(corruptWorkDir, { force: true, recursive: true }).catch(() => undefined); + if (cleanupStarted) { + await runToolsPackJson('stop').catch((error: unknown) => { + console.error('failed to stop packaged windows app during rollback cleanup', error); + }); + } + if (cleanupInstalled) { + await runToolsPackJson('uninstall', ['--remove-product-user-data']).catch((error: unknown) => { + console.error('failed to uninstall packaged windows app during rollback cleanup', error); + }); + } + } + }, 720_000); }); winOnboardingDescribe('packaged windows onboarding AMR smoke', () => { @@ -974,7 +1325,7 @@ type PayloadUpdateSummary = { identity: DesktopIdentityMarker; launcherAfterConfirm: LauncherSnapshot; popup: UpdaterPopupEvalValue; - pptx: PptxExportEvalValue; + pptx: PptxExportEvalValue | { skipped: true }; terminal: NonNullable; targetVersion: string; }; @@ -995,11 +1346,97 @@ type InstallerFallbackSummary = { targetVersion: string; }; -async function runPayloadUpdateAcceptance(options: { - expectedVersion: string | null; +type UpdaterRecoverySummary = { + cleared: NonNullable; + downloadedBeforeClear: NonNullable; + installer: InstallerFallbackSummary; + popup: UpdaterPopupEvalValue; + terminal: NonNullable; +}; + +async function runSameVersionUpdaterRecoveryAcceptance(options: { + expectedInstalledVersion: string; + fixture: ToolsServeUpdaterFixture; + installDir: string; persistedProjectId: string | null; + targetVersion: string; +}): Promise { + const stop = await runToolsPackJson('stop'); + expect(stop.status).not.toBe('partial'); + expect(stop.remainingPids).toEqual([]); + const start = await runToolsPackJson('start'); + expect(start.source).toBe('installed'); + const running = await waitForHealthyDesktopVersion(options.targetVersion, null); + + const downloadedInspect = await waitForDownloadedUpdater( + options.targetVersion, + 'installer', + 120_000, + options.targetVersion, + ); + if (downloadedInspect.update == null) { + throw new Error('same-version reinstall did not return updater status'); + } + expect(downloadedInspect.update.reinstall).toEqual({ + installedVersion: options.expectedInstalledVersion, + minVersion: options.targetVersion, + reason: 'outer-below-min', + url: 'https://example.test/updater-recovery', + }); + expect(downloadedInspect.status?.pid).toBe(running.status?.pid); + + const popup = await openReadyUpdaterPrompt(options.targetVersion); + expect(popup.visible).toBe(true); + expect(popup.installButtonVisible).toBe(true); + expect(popup.reinstallLinkVisible).toBe(true); + + const clearedInspect = await runToolsPackJson('inspect', ['--update-action', 'clear-cache']); + if (clearedInspect.update == null) throw new Error('clear-cache did not return updater status'); + expect(clearedInspect.update.state).toBe('idle'); + expect(clearedInspect.update.active).toBeUndefined(); + expect(clearedInspect.update.downloadPath).toBeUndefined(); + expect(clearedInspect.update.reinstall).toBeUndefined(); + expect(clearedInspect.launcher.active).toEqual(downloadedInspect.launcher.active); + expect(clearedInspect.launcher.lastSuccessful).toEqual(downloadedInspect.launcher.lastSuccessful); + + const installer = await runInstallerFallbackAcceptance({ + expectedCurrentVersion: options.targetVersion, + expectedVersion: options.targetVersion, + fixture: options.fixture, + installDir: options.installDir, + persistedProjectId: options.persistedProjectId, + }); + const installedConfig = JSON.parse( + await readFile(join(options.installDir, 'resources', 'open-design-config.json'), 'utf8'), + ) as { appVersion?: unknown }; + expect(installedConfig.appVersion).toBe(options.targetVersion); + + const terminalInspect = await waitForTerminalUpdateState(options.targetVersion); + if (terminalInspect.update == null) throw new Error('reinstalled outer did not return terminal updater status'); + expect(terminalInspect.update.reinstall).toBeUndefined(); + + return { + cleared: clearedInspect.update, + downloadedBeforeClear: downloadedInspect.update, + installer, + popup, + terminal: terminalInspect.update, + }; +} + +async function runPayloadUpdateAcceptance(options: { + expectedCurrentVersion?: string; + expectedVersion: string | null; + legacyInstalledExecutablePath?: string; + persistedProjectId: string | null; + verifyPptx?: boolean; }): Promise { - const downloadedInspect = await waitForDownloadedUpdater(options.expectedVersion, 'payload'); + const downloadedInspect = await waitForDownloadedUpdater( + options.expectedVersion, + 'payload', + 120_000, + options.expectedCurrentVersion, + ); if (downloadedInspect.update == null) throw new Error('payload update download did not return update status'); const targetVersion = downloadedInspect.update.availableVersion; if (targetVersion == null || targetVersion.length === 0) { @@ -1039,14 +1476,22 @@ async function runPayloadUpdateAcceptance(options: { expect(postUpdateInspect.launcher.attempt).toBeNull(); assertSettledDesktopHandoff(postUpdateInspect.launcher.handoff); const identity = await readDesktopIdentityMarker(); - assertPayloadDesktopIdentity(identity, postUpdateInspect.launcher, targetVersion); + await assertPayloadDesktopIdentity( + identity, + postUpdateInspect.launcher, + targetVersion, + options.legacyInstalledExecutablePath, + ); - const pptxExpression = options.persistedProjectId == null - ? pptxExportExpression - : existingProjectPptxExportExpression(options.persistedProjectId); - const pptxInspect = await runToolsPackJson('inspect', ['--expr', pptxExpression]); - const pptx = assertPptxExportEvalValue(pptxInspect.eval?.value); - if (options.persistedProjectId != null) expect(pptx.projectId).toBe(options.persistedProjectId); + let pptx: PayloadUpdateSummary['pptx'] = { skipped: true }; + if (options.verifyPptx !== false) { + const pptxExpression = options.persistedProjectId == null + ? pptxExportExpression + : existingProjectPptxExportExpression(options.persistedProjectId); + const pptxInspect = await runToolsPackJson('inspect', ['--expr', pptxExpression]); + pptx = assertPptxExportEvalValue(pptxInspect.eval?.value); + if (options.persistedProjectId != null) expect(pptx.projectId).toBe(options.persistedProjectId); + } const terminal = await waitForTerminalUpdateState(targetVersion); if (terminal.update == null) throw new Error('payload update terminal state did not return update status'); @@ -1074,7 +1519,12 @@ async function runPayloadUpdateAcceptance(options: { expect(coldInspect.launcher.attempt).toBeNull(); assertSettledDesktopHandoff(coldInspect.launcher.handoff); const coldIdentity = await readDesktopIdentityMarker(); - assertPayloadDesktopIdentity(coldIdentity, coldInspect.launcher, targetVersion); + await assertPayloadDesktopIdentity( + coldIdentity, + coldInspect.launcher, + targetVersion, + options.legacyInstalledExecutablePath, + ); expect(coldIdentity.pid).not.toBe(identity.pid); return { coldStart: { @@ -1096,6 +1546,7 @@ async function runPayloadUpdateAcceptance(options: { } async function runInstallerFallbackAcceptance(options: { + expectedCurrentVersion?: string; expectedVersion: string | null; fixture: ToolsServeUpdaterFixture | null; installDir: string; @@ -1103,7 +1554,12 @@ async function runInstallerFallbackAcceptance(options: { }): Promise { if (options.fixture == null) throw new Error('installer fallback requires a tools-serve fixture'); if (options.fixture.info.artifactPath == null) throw new Error('installer fallback fixture did not expose its artifact path'); - const downloadedInspect = await waitForDownloadedUpdater(options.expectedVersion, 'installer'); + const downloadedInspect = await waitForDownloadedUpdater( + options.expectedVersion, + 'installer', + 120_000, + options.expectedCurrentVersion, + ); if (downloadedInspect.update == null) throw new Error('installer update download did not return update status'); const targetVersion = downloadedInspect.update.availableVersion; const downloadPath = downloadedInspect.update.downloadPath; @@ -1121,14 +1577,17 @@ async function runInstallerFallbackAcceptance(options: { join(fixtureNamespaceRoot, 'logs', 'nsis.log'), ); expect(install.code).toBe(0); - expect(install.nsisLogTail.join('\n')).toContain('running instances detected before silent install'); - expect(install.nsisLogTail.join('\n')).toMatch(/running instances close via (?:pwsh|powershell)\.exe exit=0/); + assertTransactionalInPlaceInstallLog(install.nsisLogTail); process.env.OD_UPDATE_CURRENT_VERSION = targetVersion; const start = await runToolsPackJsonForVersion('start', targetVersion); expect(start.source).toBe('installed'); expect(start.executablePath).toBe(join(options.installDir, 'Open Design.exe')); - const postInstallInspect = await waitForHealthyDesktopVersion(targetVersion, downloadedInspect.status?.pid, false); + // The updater-owned installer may preserve the already-confirmed payload + // desktop while replacing the physical outer. Verify continuity here; the + // explicit full stop + installed-outer cold start below owns the stronger + // process-generation assertion. + const postInstallInspect = await waitForHealthyDesktopVersion(targetVersion, null, false); const health = assertHealthEvalValue(postInstallInspect.eval?.value); expect(health.status).toBe(200); expect(health.health.ok).toBe(true); @@ -1219,6 +1678,19 @@ async function runToolsPackJsonForVersion( } } +function assertTransactionalInPlaceInstallLog(lines: string[]): void { + const log = lines.join('\n'); + expect(log).toContain('existing installation found; silent install will overwrite it'); + // The installer closes running instances via pwsh.exe, falling back to + // powershell.exe (#2799), before quarantining the old tree and atomically + // committing the replacement. These lifecycle events are the stable + // transaction contract; the older "running instances detected" prose is no + // longer emitted by the current NSIS implementation. + expect(log).toMatch(/running instances close via (?:pwsh|powershell)\.exe exit=0/); + expect(log).toMatch(/event=install_dir_after_quarantine .* exists=1/); + expect(log).toMatch(/event=install_dir_after_commit .* exists=1/); + expect(log).toContain('install transaction cleanup exit=0'); +} async function runDirectInstaller( installerPath: string, @@ -1269,8 +1741,12 @@ async function readNsisLogLines(nsisLogPath = join(outputNamespaceRoot, 'logs', return raw.split(/\r?\n/).filter((line) => line.length > 0); } -async function resolveLocalUpdateFixture(): Promise<{ installerPath: string; payloadPath: string; targetVersion: string }> { - const fallbackBuildJsonPath = resolveFallbackUpdateBuildJsonPath(); +async function resolveLocalUpdateFixture( + explicitBuildJsonPath?: string, +): Promise<{ installerPath: string; payloadPath: string; targetVersion: string }> { + const fallbackBuildJsonPath = explicitBuildJsonPath == null + ? resolveFallbackUpdateBuildJsonPath() + : resolveFromWorkspace(explicitBuildJsonPath); if (fallbackBuildJsonPath == null) { throw new Error( 'full packaged windows payload smoke requires update payload metadata; set OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL or provide windows-tools-pack-update-build.json next to OD_PACKAGED_E2E_BUILD_JSON_PATH', @@ -1288,7 +1764,7 @@ async function resolveLocalUpdateFixture(): Promise<{ installerPath: string; pay throw new Error(`upgrade build metadata missing payloadPath: ${fallbackBuildJsonPath}`); } const targetVersion = - updateVersion ?? + (explicitBuildJsonPath == null ? updateVersion : null) ?? (typeof updateBuild.latestYmlPath === 'string' && updateBuild.latestYmlPath.length > 0 ? await readLatestYmlVersion(updateBuild.latestYmlPath) : null); @@ -1306,6 +1782,7 @@ async function waitForDownloadedUpdater( expectedVersion: string | null, expectedArtifactType: UpdateFixtureMode, timeoutMs = 120_000, + expectedCurrentVersion = updateScenario.expectedCurrentVersion, ): Promise { const startedAt = Date.now(); let lastResult: unknown = null; @@ -1325,7 +1802,7 @@ async function waitForDownloadedUpdater( } expect(inspect.update.artifact?.type).toBe(expectedArtifactType); expect(inspect.update.channel).toBe(updateScenario.channel); - expect(inspect.update.currentVersion).toBe(updateScenario.expectedCurrentVersion); + expect(inspect.update.currentVersion).toBe(expectedCurrentVersion); return inspect; } } catch (error) { @@ -1606,6 +2083,108 @@ async function clickPackagedOnboardingBack(): Promise { } } +async function repackWinPayloadFixture( + payloadSevenZPath: string, + workDir: string, + outputName: string, + sevenZipExe: string, + mutate: (extractRoot: string, manifest: { entry?: { executable?: string }; version?: string }) => Promise, +): Promise { + const extractRoot = join(workDir, `${outputName}-extract`); + await rm(extractRoot, { force: true, recursive: true }); + await mkdir(extractRoot, { recursive: true }); + await execFileAsync(sevenZipExe, ['x', '-y', `-o${extractRoot}`, payloadSevenZPath]); + const manifestPath = join(extractRoot, 'manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + entry?: { executable?: string }; + version?: string; + }; + await mutate(extractRoot, manifest); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + const archivePath = join(workDir, `${outputName}.7z`); + await rm(archivePath, { force: true }); + await execFileAsync(sevenZipExe, ['a', '-t7z', '-m0=LZMA2', '-mx=1', '-mf=off', archivePath, '.'], { + cwd: extractRoot, + maxBuffer: 64 * 1024 * 1024, + }); + return archivePath; +} + +/** + * Build a checksum-valid payload archive whose desktop executable spawns and + * exits before any launcher bookkeeping — the faithful shape of a broken + * release that passes every integrity gate and then dies pre-main. A plain + * script cannot stand in for the exe on Windows (CreateProcess would fail the + * spawn outright, which is the other, already-covered failure path), so the + * stub is a real executable that ignores its argv and exits immediately. + */ +async function buildCorruptedWinPayloadFixture( + payloadSevenZPath: string, + workDir: string, + sevenZipExe: string, +): Promise { + return await repackWinPayloadFixture(payloadSevenZPath, workDir, 'corrupt-payload', sevenZipExe, async (extractRoot, manifest) => { + const executableRelPath = manifest.entry?.executable; + if (executableRelPath == null || executableRelPath.length === 0) { + throw new Error(`payload manifest has no entry.executable: ${payloadSevenZPath}`); + } + const stubSource = join(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows', 'System32', 'where.exe'); + await copyFile(stubSource, join(extractRoot, executableRelPath)); + }); +} + +/** + * Re-version a healthy payload archive to the next counted release. Real + * recovery releases ship as version+1 (versioned artifacts are immutable), so + * the self-heal update must arrive under a bumped version rather than + * overwriting the broken pointer's version root. The desktop binary is + * unchanged — the running version is config/manifest-driven. + */ +async function buildVersionBumpedWinPayloadFixture( + payloadSevenZPath: string, + workDir: string, + sevenZipExe: string, + bumpedVersion: string, +): Promise { + return await repackWinPayloadFixture(payloadSevenZPath, workDir, 'healed-payload', sevenZipExe, async (extractRoot, manifest) => { + manifest.version = bumpedVersion; + const executableRelPath = manifest.entry?.executable; + if (executableRelPath == null || executableRelPath.length === 0) { + throw new Error(`payload manifest has no entry.executable: ${payloadSevenZPath}`); + } + // /.exe → /resources/open-design-config.json + const configPath = join(extractRoot, dirname(executableRelPath), 'resources', 'open-design-config.json'); + const config = JSON.parse(await readFile(configPath, 'utf8')) as { appVersion?: string }; + config.appVersion = bumpedVersion; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + }); +} + +function bumpCountedVersion(version: string): string { + const match = /^(.*[.-](?:beta|betas|prerelease|preview))\.(\d+)$/.exec(version); + if (match?.[1] == null || match[2] == null) { + throw new Error(`rollback acceptance requires a counted version to bump: ${version}`); + } + return `${match[1]}.${Number(match[2]) + 1}`; +} + +async function waitForDesktopGone(label: string, timeoutMs = 120_000): Promise { + const startedAt = Date.now(); + let lastResult: unknown = null; + while (Date.now() - startedAt < timeoutMs) { + try { + const inspect = await runToolsPackJson('inspect'); + lastResult = inspect; + if (inspect.status == null || inspect.status.state !== 'running') return; + } catch { + // A dead desktop IPC socket is exactly the expected terminal state. + return; + } + await delay(1000); + } + throw new Error(`${label}: desktop still running: ${formatUnknown(lastResult)}`); +} + async function waitForTerminalUpdateState(expectedVersion: string): Promise { const timeoutMs = 60_000; const startedAt = Date.now(); @@ -1730,7 +2309,7 @@ async function printUpdaterHelperLogs(): Promise { } async function printLauncherRuntimeSnapshot(): Promise { - const runtimePath = join(toolsPackDir, 'runtime', 'win', 'launcher', 'channels', updateScenario.channel, 'namespaces', namespace, 'runtime.json'); + const runtimePath = join(launcherNamespaceRoot, 'runtime.json'); const content = await readFile(runtimePath, 'utf8').catch(() => null); console.error(`[launcher-runtime] ${runtimePath}`); console.error(content?.trim() ?? '(missing)'); @@ -1751,14 +2330,38 @@ async function readDesktopIdentityMarker(): Promise { return value as DesktopIdentityMarker; } -function assertPayloadDesktopIdentity( +async function assertPayloadDesktopIdentity( identity: DesktopIdentityMarker, launcher: LauncherSnapshot, version: string, -): void { + legacyInstalledExecutablePath?: string, +): Promise { const payloadRoot = join(launcher.versionsRoot, version, 'payload'); expect(identity.pid).toBeGreaterThan(0); - expectPathInside(identity.executablePath, payloadRoot); + if (isPathInside(identity.executablePath, payloadRoot)) return; + + if (legacyInstalledExecutablePath == null) { + expectPathInside(identity.executablePath, payloadRoot); + return; + } + + expect(normalizePathForComparison(resolve(identity.executablePath))).toBe( + normalizePathForComparison(resolve(legacyInstalledExecutablePath)), + ); + const resourceRoot = await readDesktopStartupResourceRoot(identity.pid); + expectPathInside(resourceRoot, join(payloadRoot, 'resources', 'open-design')); +} + +async function readDesktopStartupResourceRoot(pid: number): Promise { + const logPath = join(runtimeNamespaceRoot, 'logs', 'desktop', 'latest.log'); + const lines = (await readFile(logPath, 'utf8')).split(/\r?\n/u).reverse(); + for (const line of lines) { + if (line.trim().length === 0) continue; + const entry = JSON.parse(line) as unknown; + if (!isRecord(entry) || entry.message !== 'packaged desktop starting' || !isRecord(entry.meta)) continue; + if (entry.meta.pid === pid && typeof entry.meta.resourceRoot === 'string') return entry.meta.resourceRoot; + } + throw new Error(`packaged desktop startup resource root not found for pid ${pid} in ${logPath}`); } function assertPptxExportEvalValue(value: unknown): PptxExportEvalValue { @@ -1822,6 +2425,7 @@ function asUpdaterPopupEvalValue(value: unknown): UpdaterPopupEvalValue | null { if (!isRecord(value)) return null; if (typeof value.visible !== 'boolean') return null; if (typeof value.installButtonVisible !== 'boolean') return null; + if (typeof value.reinstallLinkVisible !== 'boolean') return null; if (value.text != null && typeof value.text !== 'string') return null; if (value.title != null && typeof value.title !== 'string') return null; return value as UpdaterPopupEvalValue; @@ -1947,6 +2551,13 @@ async function resetPackagedRuntimeNamespaceRoot(namespaceRoot: string): Promise await rm(namespaceRoot, { force: true, recursive: true }); } +async function resetPackagedUpdaterNamespaceRoots(): Promise { + await Promise.all([ + resetPackagedRuntimeNamespaceRoot(runtimeNamespaceRoot), + resetPackagedRuntimeNamespaceRoot(launcherNamespaceRoot), + ]); +} + // Reset every per-namespace runtime state directory before a fresh-onboarding // start, EXCEPT the installed app payload (`install/`). On Windows the install // lives UNDER the runtime namespace root, so — unlike the macOS smoke, which @@ -2028,6 +2639,18 @@ function normalizeOptionalEnv(value: string | undefined): string | null { return normalized == null || normalized.length === 0 ? null : normalized; } +function resolveOptionalFixturePort(value: string | undefined): number | null { + const normalized = normalizeOptionalEnv(value); + if (normalized == null) return null; + const port = Number(normalized); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error( + `OD_PACKAGED_E2E_WIN_UPDATE_FIXTURE_PORT must be an integer between 1 and 65535, received ${JSON.stringify(normalized)}`, + ); + } + return port; +} + function resolveUpdateFixtureMode(value: string | undefined): UpdateFixtureMode { const normalized = normalizeOptionalEnv(value) ?? 'payload'; if (normalized === 'installer' || normalized === 'payload') return normalized; diff --git a/e2e/tests/packaged-launcher-update-loop.test.ts b/e2e/tests/packaged-launcher-update-loop.test.ts index d220deba7c..8c30c9758b 100644 --- a/e2e/tests/packaged-launcher-update-loop.test.ts +++ b/e2e/tests/packaged-launcher-update-loop.test.ts @@ -77,7 +77,10 @@ type PackagedLauncherRuntimeModule = { resolvePackagedLauncherRuntime: ( config: PackagedConfigLike, paths: PackagedPaths, - options?: { currentExecutablePath?: string }, + options?: { + currentExecutablePath?: string; + delegated?: { generation: number; version: string }; + }, ) => Promise; }; @@ -420,6 +423,7 @@ describe("packaged launcher payload update loop", () => { const promoted = await resolvePackagedLauncherRuntime(config, paths, { currentExecutablePath: testCase.expectedPayloadExecutablePath(paths.installationRoot, config.namespace), + delegated: { generation: 1, version: testCase.promotedVersion }, }); expect(promoted.source).toBe("payload"); expect(promoted.targetVersion).toBe(testCase.promotedVersion); diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index 8e2d331132..30c898f09c 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -2569,6 +2569,16 @@ process.stdin.on("end", () => { expect(winBuildScript).not.toContain("corepack"); expect(winBuildScript).not.toContain("pnpm install"); }); + + it("resolves the generated Windows update fixture outside the measured build child scope", async () => { + const winBuildScript = await readFile(releaseBetaWindowsBuildScriptPath, "utf8"); + + expect(winBuildScript).toMatch( + /Measure-Step "tools-pack win build update fixture" \{[\s\S]*?\r?\n \}\r?\n \$updateBuild = Get-Content -LiteralPath \$fixtureJsonPath -Raw \| ConvertFrom-Json\r?\n \$localUpdateArtifactPath = \[string\]\$updateBuild\.installerPath/, + ); + expect(winBuildScript).toContain('$env:OD_PACKAGED_E2E_WIN_UPDATE_FIXTURE = "tools-serve"'); + expect(winBuildScript).toContain("$env:OD_PACKAGED_E2E_WIN_UPDATE_ARTIFACT_PATH = $localUpdateArtifactPath"); + }); }); function expectChannelWorkflowNamespaces( diff --git a/e2e/tests/updater-fixture.test.ts b/e2e/tests/updater-fixture.test.ts new file mode 100644 index 0000000000..b9bcd85bcd --- /dev/null +++ b/e2e/tests/updater-fixture.test.ts @@ -0,0 +1,53 @@ +import { createServer } from 'node:http'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { startToolsServeUpdaterFixture, type ToolsServeUpdaterFixture } from '@/vitest/tools-serve-updater-fixture'; + +const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); + +describe('packaged updater fixture bridge', () => { + let fixture: ToolsServeUpdaterFixture | null = null; + + afterEach(async () => { + await fixture?.close(); + fixture = null; + }); + + it('[P1] carries the installed-outer reinstall floor into fixture metadata', async () => { + const port = await reserveLoopbackPort(); + fixture = await startToolsServeUpdaterFixture({ + channel: 'stable', + controlLauncherVersionMin: '0.16.0', + controlLauncherVersionUrl: 'https://example.test/updater-recovery', + platform: 'win', + port, + version: '0.16.0', + workspaceRoot, + }); + + const response = await fetch(fixture.info.metadataUrl); + expect(response.ok).toBe(true); + expect(new URL(fixture.info.metadataUrl).port).toBe(String(port)); + const metadata = await response.json() as { + control?: { launcher?: { version?: { min?: string; url?: string } } }; + }; + expect(metadata.control?.launcher?.version).toEqual({ + min: '0.16.0', + url: 'https://example.test/updater-recovery', + }); + }); +}); + +async function reserveLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + await new Promise((resolve, reject) => server.close((error) => error == null ? resolve() : reject(error))); + if (address == null || typeof address === 'string') throw new Error('failed to reserve loopback port'); + return address.port; +} diff --git a/packages/host/src/actions.ts b/packages/host/src/actions.ts index 44beadd9bc..20fdbaec5b 100644 --- a/packages/host/src/actions.ts +++ b/packages/host/src/actions.ts @@ -210,6 +210,14 @@ export async function downloadHostUpdater( return await runHostUpdaterAction(OPEN_DESIGN_HOST_UPDATER_ACTIONS.DOWNLOAD, options, scope); } +/** Clear the host updater/launcher caches and reset one-shot update state. */ +export async function clearHostUpdaterCache( + options?: OpenDesignHostUpdaterActionOptions, + scope: OpenDesignHostGlobalScope = globalThis, +): Promise { + return await runHostUpdaterAction(OPEN_DESIGN_HOST_UPDATER_ACTIONS.CLEAR_CACHE, options, scope); +} + /** Trigger a host updater install. */ export async function installHostUpdater( options?: OpenDesignHostUpdaterActionOptions, diff --git a/packages/host/src/index.ts b/packages/host/src/index.ts index 1781316da4..655ef29180 100644 --- a/packages/host/src/index.ts +++ b/packages/host/src/index.ts @@ -52,6 +52,8 @@ export type { OpenDesignHostUpdaterReleaseLifecycleState, OpenDesignHostUpdaterCacheLifecycleSummary, OpenDesignHostUpdaterCacheSnapshot, + OpenDesignHostUpdaterReinstallReason, + OpenDesignHostUpdaterReinstallSnapshot, OpenDesignHostUpdaterStatusSnapshot, OpenDesignHostUpdaterResult, OpenDesignHostUpdaterStatusListener, @@ -90,6 +92,7 @@ export { setHostPetVisible, getHostUpdaterStatus, checkHostUpdater, + clearHostUpdaterCache, downloadHostUpdater, installHostUpdater, quitHostAfterUpdaterInstallerOpen, diff --git a/packages/host/src/protocol.ts b/packages/host/src/protocol.ts index b900f53323..1d94e45575 100644 --- a/packages/host/src/protocol.ts +++ b/packages/host/src/protocol.ts @@ -108,6 +108,7 @@ export type OpenDesignHostBrowserClearDataOptions = { export const OPEN_DESIGN_HOST_UPDATER_ACTIONS = Object.freeze({ CHECK: "check", + CLEAR_CACHE: "clear-cache", DOWNLOAD: "download", INSTALL: "install", QUIT: "quit", @@ -217,7 +218,7 @@ export type OpenDesignHostUpdaterIncomingSnapshot = { version: string; }; -export type OpenDesignHostUpdaterCacheLifecycleTrigger = "cold-start" | "next-version-ready"; +export type OpenDesignHostUpdaterCacheLifecycleTrigger = "cold-start" | "manual" | "next-version-ready"; export type OpenDesignHostUpdaterReleaseLifecycleState = | "cleanup-deferred" @@ -245,6 +246,24 @@ export type OpenDesignHostUpdaterCacheSnapshot = { lifecycle?: OpenDesignHostUpdaterCacheLifecycleSummary; }; +export type OpenDesignHostUpdaterReinstallReason = + | "launcher-schema" + | "outer-below-min" + | "outer-version-unreadable"; + +/** + * Present when the release feed requires a full installer reinstall instead of + * an in-place payload update. `installedVersion` is the physically installed + * outer package version; `url` is an optional operator-supplied explanation + * link. + */ +export type OpenDesignHostUpdaterReinstallSnapshot = { + installedVersion?: string; + minVersion?: string; + reason: OpenDesignHostUpdaterReinstallReason; + url?: string; +}; + export type OpenDesignHostUpdaterStatusSnapshot = { active?: OpenDesignHostUpdaterReleaseSnapshot; arch: string; @@ -267,6 +286,7 @@ export type OpenDesignHostUpdaterStatusSnapshot = { paths?: OpenDesignHostUpdaterPathSnapshot; platform: string; progress?: OpenDesignHostUpdaterProgressSnapshot; + reinstall?: OpenDesignHostUpdaterReinstallSnapshot; state: OpenDesignHostUpdaterState; supported: boolean; }; @@ -319,6 +339,7 @@ export type OpenDesignHostBridge = { }; updater: { check(options?: OpenDesignHostUpdaterActionOptions): Promise; + "clear-cache"(options?: OpenDesignHostUpdaterActionOptions): Promise; download(options?: OpenDesignHostUpdaterActionOptions): Promise; install(options?: OpenDesignHostUpdaterActionOptions): Promise; quit(options?: OpenDesignHostUpdaterActionOptions): Promise; diff --git a/packages/host/src/testing.ts b/packages/host/src/testing.ts index a7fabe2f51..dfb0289824 100644 --- a/packages/host/src/testing.ts +++ b/packages/host/src/testing.ts @@ -76,6 +76,7 @@ function defaultHost(): OpenDesignHostBridge { }, updater: { check: async () => updaterStatus, + "clear-cache": async () => updaterStatus, download: async () => updaterStatus, install: async () => updaterStatus, quit: async () => ({ ok: true }), diff --git a/packages/launcher-proto/src/index.ts b/packages/launcher-proto/src/index.ts index ed437379d1..613fb695ec 100644 --- a/packages/launcher-proto/src/index.ts +++ b/packages/launcher-proto/src/index.ts @@ -8,6 +8,8 @@ export const LAUNCHER_AFTER_QUIT_FLAG = "--od-launcher-after-quit" as const; export const LAUNCHER_AFTER_QUIT_TARGET_PID_ARG = "--od-launcher-target-pid" as const; export const LAUNCHER_AFTER_QUIT_TIMEOUT_MS_ARG = "--od-launcher-timeout-ms" as const; export const LAUNCHER_HANDOFF_RESUME_ARG = "--od-launcher-resume-handoff" as const; +export const LAUNCHER_DELEGATED_GENERATION_ARG = "--od-launcher-delegated-generation" as const; +export const LAUNCHER_DELEGATED_VERSION_ARG = "--od-launcher-delegated-version" as const; export const LAUNCHER_CHANNELS = Object.freeze({ BETA: RELEASE_CHANNELS.BETA, @@ -132,6 +134,7 @@ export type LauncherCleanupDescriptor = { export type LauncherTargetSelection = | { pointer: LauncherVersionPointer; reason: "active"; selected: true } + | { pointer: LauncherVersionPointer; reason: "active-delegated"; selected: true } | { pointer: LauncherVersionPointer; reason: "active-resume"; selected: true } | { pointer: LauncherVersionPointer; reason: "last-successful"; selected: true } | { reason: "no-runtime-target"; selected: false }; @@ -280,6 +283,40 @@ export function parseLauncherAfterQuitArgs(args: readonly string[]): LauncherAft }; } +function normalizeLauncherGeneration(value: unknown): number { + const generation = typeof value === "string" && value.length > 0 ? Number(value) : value; + if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 0) { + throw new LauncherProtocolError(`launcher generation must be a non-negative safe integer: ${String(value)}`); + } + return generation; +} + +/** + * Delegated-launch marker: a parent that pre-armed attempt.json for the + * pointer it is about to spawn (packaged outer delegation, updater payload + * relaunch) passes the pointer along so the spawned payload can tell its own + * in-progress attempt apart from a previous failed launch. Without the + * pre-arm, a payload that dies before reaching its own bookkeeping leaves no + * rollback evidence and the next cold start retries the same broken payload + * forever. + */ +export function buildLauncherDelegatedArgs(pointer: LauncherVersionPointer): string[] { + return [ + LAUNCHER_DELEGATED_GENERATION_ARG, + normalizeLauncherGeneration(pointer.generation).toString(), + LAUNCHER_DELEGATED_VERSION_ARG, + normalizeLauncherVersion(pointer.version), + ]; +} + +export function parseLauncherDelegatedArgs(args: readonly string[]): LauncherVersionPointer | null { + if (!args.includes(LAUNCHER_DELEGATED_GENERATION_ARG)) return null; + return { + generation: normalizeLauncherGeneration(valueAfterArg(args, LAUNCHER_DELEGATED_GENERATION_ARG)), + version: normalizeLauncherVersion(valueAfterArg(args, LAUNCHER_DELEGATED_VERSION_ARG)), + }; +} + export function buildLauncherHandoffResumeArgs(request: LauncherHandoffResumeRequest): string[] { return [LAUNCHER_HANDOFF_RESUME_ARG, normalizeLauncherHandoffId(request.handoffId)]; } @@ -609,11 +646,13 @@ export function validateLauncherCleanupDescriptor( export function selectLauncherRuntimeTarget(input: { attempted?: LauncherAttemptDescriptor | null; + delegated?: LauncherVersionPointer | null; resume?: LauncherVersionPointer | null; runtime: LauncherRuntimeDescriptor; }): LauncherTargetSelection { const active = normalizePointer(input.runtime.active); const lastSuccessful = normalizePointer(input.runtime.lastSuccessful); + const delegated = input.delegated == null ? null : normalizePointer(input.delegated); const resume = input.resume == null ? null : normalizePointer(input.resume); const attempted = input.attempted == null ? null @@ -640,6 +679,17 @@ export function selectLauncherRuntimeTarget(input: { ) { return { pointer: active, reason: "active-resume", selected: true }; } + // A delegated pointer matching active marks THIS launch's pre-armed + // attempt (the parent wrote it just before spawning us) — the launch in + // progress, not a previous failure. Only an exact active match qualifies; + // a stale pointer from an older generation must not defeat the rollback. + if ( + delegated != null && + delegated.version === active.version && + delegated.generation === active.generation + ) { + return { pointer: active, reason: "active-delegated", selected: true }; + } if (lastSuccessful == null) return { pointer: active, reason: "active", selected: true }; return { pointer: lastSuccessful, reason: "last-successful", selected: true }; } diff --git a/packages/launcher-proto/tests/index.test.ts b/packages/launcher-proto/tests/index.test.ts index 86c29c6646..e7d98f0623 100644 --- a/packages/launcher-proto/tests/index.test.ts +++ b/packages/launcher-proto/tests/index.test.ts @@ -6,9 +6,11 @@ import { LAUNCHER_SCHEMA_VERSION, LauncherProtocolError, buildLauncherAfterQuitArgs, + buildLauncherDelegatedArgs, buildLauncherHandoffResumeArgs, compareLauncherVersions, parseLauncherAfterQuitArgs, + parseLauncherDelegatedArgs, parseLauncherHandoffResumeArgs, resolveLauncherPaths, resolveLauncherVersionPaths, @@ -157,6 +159,68 @@ describe("launcher runtime descriptors", () => { }); }); + it("treats a delegated pre-armed attempt as the launch in progress, not a failed launch", () => { + // The delegating parent (outer or updater relaunch) arms attempt.json + // BEFORE spawning the payload so a payload that dies before reaching its + // own bookkeeping still leaves rollback evidence. The spawned payload + // carries the delegated pointer and must not mistake that fresh attempt + // for a previous failed launch. + const attempted = { + channel: "beta", + generation: 2, + namespace: "release-beta", + schemaVersion: LAUNCHER_SCHEMA_VERSION, + version: "0.8.1-beta.2", + } as const; + + expect( + selectLauncherRuntimeTarget({ + attempted, + delegated: { generation: 2, version: "0.8.1-beta.2" }, + runtime, + }), + ).toEqual({ + pointer: { generation: 2, version: "0.8.1-beta.2" }, + reason: "active-delegated", + selected: true, + }); + + // A stale delegated pointer from an older generation must not defeat the + // rollback: only an exact active match is the launch in progress. + expect( + selectLauncherRuntimeTarget({ + attempted, + delegated: { generation: 1, version: "0.8.1-beta.1" }, + runtime, + }), + ).toEqual({ + pointer: { generation: 1, version: "0.8.1-beta.1" }, + reason: "last-successful", + selected: true, + }); + + // Without a matching attempt the delegated pointer is irrelevant. + expect( + selectLauncherRuntimeTarget({ + delegated: { generation: 2, version: "0.8.1-beta.2" }, + runtime, + }), + ).toEqual({ + pointer: { generation: 2, version: "0.8.1-beta.2" }, + reason: "active", + selected: true, + }); + }); + + it("round-trips launcher delegated argv", () => { + const args = buildLauncherDelegatedArgs({ generation: 2, version: "0.8.1-beta.2" }); + expect(parseLauncherDelegatedArgs(["node", "app", ...args])).toEqual({ + generation: 2, + version: "0.8.1-beta.2", + }); + expect(parseLauncherDelegatedArgs(["node", "app"])).toBeNull(); + }); + it("falls back cleanly when no active runtime target exists", () => { expect(selectLauncherRuntimeTarget({ runtime: { diff --git a/packages/sidecar-proto/src/index.ts b/packages/sidecar-proto/src/index.ts index 054a6c0750..e2be32af6b 100644 --- a/packages/sidecar-proto/src/index.ts +++ b/packages/sidecar-proto/src/index.ts @@ -98,6 +98,7 @@ export const SIDECAR_MESSAGES = Object.freeze({ export const DESKTOP_UPDATE_ACTIONS = Object.freeze({ CHECK: "check", + CLEAR_CACHE: "clear-cache", DOWNLOAD: "download", INSTALL: "install", STATUS: "status", @@ -420,7 +421,7 @@ export type DesktopUpdateIncomingSnapshot = { version: string; }; -export type DesktopUpdateCacheLifecycleTrigger = "cold-start" | "next-version-ready"; +export type DesktopUpdateCacheLifecycleTrigger = "cold-start" | "manual" | "next-version-ready"; export type DesktopUpdateReleaseLifecycleState = | "cleanup-deferred" @@ -448,6 +449,30 @@ export type DesktopUpdateCacheSnapshot = { lifecycle?: DesktopUpdateCacheLifecycleSummary; }; +export const DESKTOP_UPDATE_REINSTALL_REASONS = Object.freeze({ + LAUNCHER_SCHEMA: "launcher-schema", + OUTER_BELOW_MIN: "outer-below-min", + OUTER_VERSION_UNREADABLE: "outer-version-unreadable", +} as const); + +export type DesktopUpdateReinstallReason = + (typeof DESKTOP_UPDATE_REINSTALL_REASONS)[keyof typeof DESKTOP_UPDATE_REINSTALL_REASONS]; + +/** + * Present on a status snapshot when the release feed requires a full installer + * reinstall instead of an in-place payload update. `installedVersion` is the + * physically installed outer package version (not the running payload version); + * it is omitted when the outer bundle config could not be read. `url` is an + * optional operator-supplied explanation link from + * `control.launcher.version.url`. + */ +export type DesktopUpdateReinstallSnapshot = { + installedVersion?: string; + minVersion?: string; + reason: DesktopUpdateReinstallReason; + url?: string; +}; + export type DesktopUpdateStatusSnapshot = { active?: DesktopUpdateReleaseSnapshot; arch: string; @@ -470,6 +495,7 @@ export type DesktopUpdateStatusSnapshot = { paths?: DesktopUpdatePathSnapshot; platform: string; progress?: DesktopUpdateProgressSnapshot; + reinstall?: DesktopUpdateReinstallSnapshot; state: DesktopUpdateState; supported: boolean; }; @@ -827,7 +853,7 @@ function normalizeDesktopExportArtifactInput(input: unknown): DesktopExportArtif }; } -function isDesktopUpdateAction(value: unknown): value is DesktopUpdateAction { +export function isDesktopUpdateAction(value: unknown): value is DesktopUpdateAction { return Object.values(DESKTOP_UPDATE_ACTIONS).includes(value as DesktopUpdateAction); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9efebd7655..214cc8fadd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -806,6 +806,9 @@ importers: tools/release: dependencies: + '@open-design/launcher-proto': + specifier: workspace:* + version: link:../../packages/launcher-proto '@open-design/release': specifier: workspace:* version: link:../../packages/release diff --git a/tools/pack/AGENTS.md b/tools/pack/AGENTS.md index 35100c39d9..4c65090d5c 100644 --- a/tools/pack/AGENTS.md +++ b/tools/pack/AGENTS.md @@ -58,6 +58,8 @@ The runtime updater reads `https://releases.open-design.ai//latest/meta - The artifact must have a checksum, preferably `sha256Url`; the updater verifies bytes before exposing an install action. - `OD_UPDATE_CURRENT_VERSION` may override the packaged version for tests, but user-flow package validation should prefer building the package with the intended `--app-version`. - Release metadata may include `releaseNote.content`, with a `defaultLocale` and locale descriptors containing `url`, `mediaType`, `sha256`, and `size`. The updater does not currently consume this block; `tools-release` owns its publication and verification independently from updater UI behavior. +- Release metadata may include `control.launcher.version.{min, url}` — the installer-reinstall floor. The updater compares `min` against the **physically installed outer package version** (read at check time from the outer bundle's `open-design-config.json` via the launcher launch path; `OD_UPDATE_INSTALLED_VERSION` overrides it for tests), NOT the running payload version — payload updates never touch the outer bundle, so a broken outer generation must reach the installer path even when its payload is current. When the floor trips (or `min` is set but the outer version is unreadable — conservative), the updater selects the installer artifact and, when no newer release exists, offers a same-version installer reinstall; snapshot field `reinstall` carries `{reason, installedVersion, minVersion, url}` and the web UI presents the optional operator `url` as a jump link with default i18n copy as fallback. Publication: channel policy is managed as one repo-vars pair per channel — `RELEASE_LAUNCHER_VERSION_MIN_` + `RELEASE_LAUNCHER_VERSION_MIN_URL_` — passed through workflows verbatim (no YAML fallback expressions) and resolved by the shared resolver in `tools/release/src/storage/launcher-version-floor.ts`: a non-stable channel whose own pair is unset falls back to the STABLE pair as a unit, and format/https/floor validation is applied at that single point. `publish-metadata` hard-fails when `min` exceeds the release version — a floor this release cannot satisfy would make the same-version reinstall offer nag forever. `verify-metadata` re-resolves the same channel policy and checks the published block against it; `summary-metadata` surfaces the floor in the step summary. +- The updater exposes a manual disaster-recovery `clear-cache` action (`od:update:clear-cache` IPC, sidecar action `clear-cache`, Settings → About "Clear update cache" row with a two-stage inline confirm). It resets one-shot update state (downloaded release, install freeze) back to `idle`, purges `releases/`, `staging/`, `downloads/`, and `.back/`, removes a stale launcher `attempt.json` plus any non-`confirmed` desktop-handoff journal, and deletes non-retained launcher payload versions. Runtime `active`/`lastSuccessful` versions, explicit `retained` cleanup entries, `install.json`, and a `confirmed` handoff journal are never touched; locked files defer through the existing cleanup.json retry machinery. An installer helper already spawned by a prior install action is not cancelled. - Release-note source lives at `docs/CHANGELOG/v/.md`. All channels use the same publication pipeline, while stable additionally requires `en` and `zh-CN` before platform builds proceed. - Post-update "what's new" highlights are NOT carried in release `metadata.json`. The daemon's `/api/whats-new` fetches a single hand-curated document on a dedicated R2 bucket (`https://whatsnew.open-design.ai/whats-new.json`, overridable with `OD_WHATS_NEW_URL`); the web home surface shows a one-time card driven by that document's `id`, not the running version. Operators edit that one file after a release — there is no per-version publish tooling. @@ -161,7 +163,7 @@ pnpm tools-pack win cleanup --dir C:\odtp-beta-release-fixed --namespace release ### Validation matrix for updater changes -Run the narrow tests that match the surface you touched, then the repo checks: +`docs/testing/updater-lifecycle.md` is the full lifecycle-to-test coverage map (including deliberate manual-only nodes); consult it to find the owning tests for the node you touched, then run the narrow tests plus the repo checks: ```bash pnpm --filter @open-design/desktop test -- tests/main/updater.test.ts tests/main/updater-host-boundary.test.ts tests/main/preload-host-boundary.test.ts diff --git a/tools/pack/src/index.ts b/tools/pack/src/index.ts index 39cda0d288..8ecebfaaa1 100644 --- a/tools/pack/src/index.ts +++ b/tools/pack/src/index.ts @@ -73,7 +73,7 @@ function addSharedOptions(command: CacCommand) { .option("--path ", "desktop inspect screenshot path") .option("--status-poll-count ", "inspect: poll desktop/daemon/web STATUS this many times") .option("--status-poll-interval-ms ", "inspect: delay between STATUS poll samples") - .option("--update-action ", "desktop update action: status|check|download|install"); + .option("--update-action ", "desktop update action: status|check|clear-cache|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 38a7313dd9..10a972abe0 100644 --- a/tools/pack/src/mac/lifecycle.ts +++ b/tools/pack/src/mac/lifecycle.ts @@ -9,9 +9,11 @@ import { SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, + isDesktopUpdateAction, type DesktopEvalResult, type DesktopScreenshotResult, type DesktopStatusSnapshot, + type DesktopUpdateAction, type DesktopUpdateResult, type SidecarStamp, } from "@open-design/sidecar-proto"; @@ -691,10 +693,10 @@ export async function readPackedMacLogs(config: ToolPackConfig) { }; } -function resolveUpdateAction(value: string | undefined): "status" | "check" | "download" | "install" | null { +function resolveUpdateAction(value: string | undefined): DesktopUpdateAction | 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"); + if (isDesktopUpdateAction(value)) return value; + throw new Error("--update-action must be status, check, clear-cache, download, or install"); } export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string; updateAction?: string }): Promise { diff --git a/tools/pack/src/win/lifecycle.ts b/tools/pack/src/win/lifecycle.ts index e4bde7a3f1..994dddc4ac 100644 --- a/tools/pack/src/win/lifecycle.ts +++ b/tools/pack/src/win/lifecycle.ts @@ -7,10 +7,12 @@ import { SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, + isDesktopUpdateAction, type DaemonStatusSnapshot, type DesktopEvalResult, type DesktopScreenshotResult, type DesktopStatusSnapshot, + type DesktopUpdateAction, type DesktopUpdateResult, type SidecarStamp, type WebStatusSnapshot, @@ -180,7 +182,7 @@ export async function installPackedWinApp(config: ToolPackConfig): Promise removeTree(registeredPaths.installDir)); } - await measureLifecycleStep(lifecycleTimings, "ensure install parent", async () => mkdir(dirname(paths.installDir), { recursive: true })); + await measureLifecycleStep(lifecycleTimings, "ensure install directory", async () => mkdir(paths.installDir, { recursive: true })); await measureLifecycleStep(lifecycleTimings, "nsis install", async () => runTimed(paths.installTimingPath, "install", async () => { await invokeNsis(paths, paths.setupPath, installArgs(config, paths), "install"); })); @@ -475,10 +477,10 @@ export async function resetPackedWinNamespaces(config: ToolPackConfig): Promise< return { namespaces, results }; } -function resolveUpdateAction(value: string | undefined): "status" | "check" | "download" | "install" | null { +function resolveUpdateAction(value: string | undefined): DesktopUpdateAction | 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"); + if (isDesktopUpdateAction(value)) return value; + throw new Error("--update-action must be status, check, clear-cache, download, or install"); } async function requestDesktopEval( diff --git a/tools/pack/tests/release-workflows.test.ts b/tools/pack/tests/release-workflows.test.ts index 72ab3109f2..0e0fba9ce1 100644 --- a/tools/pack/tests/release-workflows.test.ts +++ b/tools/pack/tests/release-workflows.test.ts @@ -374,4 +374,42 @@ describe("release workflows", () => { expect(stablePrepare).toContain('setOutput("run_prepublish_jobs"'); expect(stablePrepare).toContain('setOutput("publish_side_effects_enabled"'); }); + + it("passes launcher version floor repo vars through to metadata publish and verify verbatim", async () => { + const [beta, betaSelfHosted, preview, prerelease, stable] = await Promise.all([ + readFile(new URL("../../../.github/workflows/release-beta.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-beta-s.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-preview.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-stable.yml", import.meta.url), "utf8"), + ]); + + const passthrough = (suffix: string): string[] => [ + `RELEASE_LAUNCHER_VERSION_MIN_${suffix}: \${{ vars.RELEASE_LAUNCHER_VERSION_MIN_${suffix} }}`, + `RELEASE_LAUNCHER_VERSION_MIN_URL_${suffix}: \${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_${suffix} }}`, + ]; + + // Each channel workflow forwards its own repo-vars pair plus the STABLE + // fallback pair verbatim; channel policy (pair-level stable fallback, + // format/https/floor validation) lives only in + // tools/release/src/storage/launcher-version-floor.ts, never in YAML. + const lanes: Array<{ minSteps: number; suffix: string; workflow: string }> = [ + { minSteps: 2, suffix: "BETA", workflow: beta }, + { minSteps: 1, suffix: "BETAS", workflow: betaSelfHosted }, + { minSteps: 2, suffix: "PREVIEW", workflow: preview }, + { minSteps: 2, suffix: "PRERELEASE", workflow: prerelease }, + ]; + for (const lane of lanes) { + for (const key of [...passthrough(lane.suffix), ...passthrough("STABLE")]) { + // publish-metadata always carries the pair; lanes with a + // verify-metadata step must carry it there too. + expect(countOccurrences(lane.workflow, key)).toBeGreaterThanOrEqual(lane.minSteps); + } + expect(lane.workflow).not.toContain(`vars.RELEASE_LAUNCHER_VERSION_MIN_${lane.suffix} ||`); + } + for (const key of passthrough("STABLE")) { + expect(countOccurrences(stable, key)).toBeGreaterThanOrEqual(2); + } + expect(stable).not.toContain("vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE ||"); + }); }); diff --git a/tools/pack/tests/win-lifecycle.test.ts b/tools/pack/tests/win-lifecycle.test.ts index d9e6687a79..024c5b9ae8 100644 --- a/tools/pack/tests/win-lifecycle.test.ts +++ b/tools/pack/tests/win-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -16,6 +16,13 @@ const matchesStampedProcess = vi.hoisted(() => ); const spawnBackgroundProcess = vi.hoisted(() => vi.fn(async () => ({ pid: 12345 }))); const stopProcesses = vi.hoisted(() => vi.fn(async () => undefined)); +const invokeNsis = vi.hoisted(() => vi.fn()); +const queryWinRegistryEntries = vi.hoisted(() => + vi.fn(async () => []), +); +const resolveWinRegisteredPaths = vi.hoisted(() => + vi.fn(async (_config, paths) => paths), +); vi.mock("@open-design/sidecar", async () => { const actual = await vi.importActual("@open-design/sidecar"); @@ -36,7 +43,26 @@ vi.mock("@open-design/platform", async () => { }; }); -const { diagnosePackedWinIpc, inspectPackedWinApp, stopPackedWinApp } = await import("../src/win/lifecycle.js"); +vi.mock("../src/win/nsis.js", async () => { + const actual = await vi.importActual("../src/win/nsis.js"); + return { + ...actual, + invokeNsis, + }; +}); + +vi.mock("../src/win/registry.js", async () => { + const actual = await vi.importActual("../src/win/registry.js"); + return { + ...actual, + queryWinRegistryEntries, + resolveWinRegisteredPaths, + }; +}); + +const { diagnosePackedWinIpc, inspectPackedWinApp, installPackedWinApp, stopPackedWinApp } = await import( + "../src/win/lifecycle.js" +); const { resolveWinPaths } = await import("../src/win/paths.js"); function createConfig(root: string): ToolPackConfig { @@ -83,6 +109,32 @@ async function writeFakeUnpackedExe(config: ToolPackConfig): Promise { await writeFile(paths.unpackedExePath, "", "utf8"); } +describe("installPackedWinApp", () => { + it("creates the exact fresh install directory before invoking transactional NSIS", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); + const config = createConfig(root); + const paths = resolveWinPaths(config); + + try { + await mkdir(dirname(paths.setupPath), { recursive: true }); + await writeFile(paths.setupPath, "", "utf8"); + invokeNsis.mockReset(); + invokeNsis.mockImplementation(async () => { + await expect(access(paths.installDir)).resolves.toBeUndefined(); + await writeFile(paths.installedExePath, "", "utf8"); + }); + + const result = await installPackedWinApp(config); + + expect(result.installDir).toBe(paths.installDir); + expect(result.lifecycleTimings.map(({ step }) => step)).toContain("ensure install directory"); + expect(invokeNsis).toHaveBeenCalledOnce(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); + describe("inspectPackedWinApp", () => { it("returns status and diagnostics when eval IPC times out", async () => { const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); diff --git a/tools/release/package.json b/tools/release/package.json index 3edb8c44f5..f439d431d8 100644 --- a/tools/release/package.json +++ b/tools/release/package.json @@ -13,6 +13,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit" }, "dependencies": { + "@open-design/launcher-proto": "workspace:*", "@open-design/release": "workspace:*", "cac": "6.7.14", "yaml": "2.9.0" diff --git a/tools/release/scripts/build-platform.ps1 b/tools/release/scripts/build-platform.ps1 index bba658d0f7..4f31668434 100644 --- a/tools/release/scripts/build-platform.ps1 +++ b/tools/release/scripts/build-platform.ps1 @@ -296,11 +296,11 @@ try { throw "tools-pack win update fixture build failed with exit code $LASTEXITCODE" } $updateOutput | Set-Content -LiteralPath $fixtureJsonPath -Encoding utf8 - $updateBuild = Get-Content -LiteralPath $fixtureJsonPath -Raw | ConvertFrom-Json - $localUpdateArtifactPath = [string]$updateBuild.installerPath - if ([string]::IsNullOrWhiteSpace($localUpdateArtifactPath)) { - throw "tools-pack win build update fixture did not report installerPath" - } + } + $updateBuild = Get-Content -LiteralPath $fixtureJsonPath -Raw | ConvertFrom-Json + $localUpdateArtifactPath = [string]$updateBuild.installerPath + if ([string]::IsNullOrWhiteSpace($localUpdateArtifactPath)) { + throw "tools-pack win build update fixture did not report installerPath" } Measure-Step "validate launcher payload update fixture" { $updateBuild = Get-Content -LiteralPath $fixtureJsonPath -Raw | ConvertFrom-Json diff --git a/tools/release/src/storage/launcher-version-floor.ts b/tools/release/src/storage/launcher-version-floor.ts new file mode 100644 index 0000000000..f99c41c245 --- /dev/null +++ b/tools/release/src/storage/launcher-version-floor.ts @@ -0,0 +1,77 @@ +import { compareLauncherVersions } from "@open-design/launcher-proto"; +import type { ReleaseChannel } from "@open-design/release"; + +/** + * Channel policy for the installer-reinstall floor published as + * `control.launcher.version.{min,url}`. + * + * Operators manage one repo-vars pair per channel — + * `RELEASE_LAUNCHER_VERSION_MIN_` plus + * `RELEASE_LAUNCHER_VERSION_MIN_URL_` — and workflows pass those vars + * through verbatim. This module is the single resolution and validation point: + * a non-stable channel whose own `MIN` is unset falls back to the STABLE pair + * as a unit (never mixing one channel's min with another's url), and every + * consumer (publish, verify) applies the same format, URL, and floor checks. + */ +export type LauncherVersionFloor = { + min: string; + url?: string; +}; + +const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; + +function channelEnvSuffix(channel: ReleaseChannel): string { + return channel.toUpperCase(); +} + +function readPair( + env: NodeJS.ProcessEnv, + suffix: string, +): LauncherVersionFloor | null { + const minKey = `RELEASE_LAUNCHER_VERSION_MIN_${suffix}`; + const urlKey = `RELEASE_LAUNCHER_VERSION_MIN_URL_${suffix}`; + const min = env[minKey]?.trim() ?? ""; + const url = env[urlKey]?.trim() ?? ""; + if (min.length === 0) { + if (url.length > 0) { + throw new Error(`RELEASE_LAUNCHER_VERSION_MIN_URL_${suffix} requires RELEASE_LAUNCHER_VERSION_MIN_${suffix}`); + } + return null; + } + if (!VERSION_PATTERN.test(min)) { + throw new Error(`${minKey} is not a valid version: ${min}`); + } + if (url.length > 0) { + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error(`${urlKey} must be an http(s) URL: ${url}`); + } + } + return { min, ...(url.length > 0 ? { url } : {}) }; +} + +export function resolveLauncherVersionFloor( + channel: ReleaseChannel, + env: NodeJS.ProcessEnv = process.env, +): LauncherVersionFloor | null { + const own = readPair(env, channelEnvSuffix(channel)); + if (own != null) return own; + if (channel === "stable") return null; + return readPair(env, "STABLE"); +} + +/** + * A floor the published release cannot satisfy would make the updater's + * same-version reinstall offer nag forever; publication (and verification) + * must refuse it. + */ +export function assertLauncherVersionFloorSatisfiable( + floor: LauncherVersionFloor, + releaseVersion: string, +): void { + if (compareLauncherVersions(floor.min, releaseVersion) > 0) { + throw new Error( + `launcher version floor ${floor.min} exceeds release version ${releaseVersion}`, + ); + } +} diff --git a/tools/release/src/storage/publish-metadata.ts b/tools/release/src/storage/publish-metadata.ts index 56f1692c97..0f0edfeb4b 100644 --- a/tools/release/src/storage/publish-metadata.ts +++ b/tools/release/src/storage/publish-metadata.ts @@ -11,6 +11,10 @@ import { } from "./common.ts"; import { assertCurrentVersionReservation, versionLockObjectKey } from "./beta-version-reservation.ts"; import { getStorageObject, putStorageObject, putStorageObjectWithStatus } from "./s3-upload.ts"; +import { + assertLauncherVersionFloorSatisfiable, + resolveLauncherVersionFloor, +} from "./launcher-version-floor.ts"; import { parseCountedReleaseVersion, parseReleaseBaseVersion, @@ -76,6 +80,29 @@ const versionLockKey = optional( const latestCasRequired = process.env.RELEASE_LATEST_CAS_REQUIRED === "true"; const storage = publishSideEffectsEnabled || versionLockRequired ? storageConfigFromEnv() : null; +// Operator-supplied installer-reinstall floor: one repo-vars pair per channel, +// resolved with pair-level stable fallback by the shared channel-policy +// resolver. Published as control.launcher.version.{min,url}; the desktop +// updater compares min against the physically installed outer package version +// and forces the installer route — including a same-version reinstall — when +// the outer is below it. +const launcherVersionFloor = resolveLauncherVersionFloor(releaseChannel); +if (launcherVersionFloor != null) { + assertLauncherVersionFloorSatisfiable(launcherVersionFloor, releaseVersion); +} +const controlBlock = launcherVersionFloor == null + ? {} + : { + control: { + launcher: { + version: { + min: launcherVersionFloor.min, + ...(launcherVersionFloor.url == null ? {} : { url: launcherVersionFloor.url }), + }, + }, + }, + }; + function readReleaseNoteMetadata(): ReturnType { if (releaseNoteManifestPath.length === 0) { if (releaseChannel === "stable") { @@ -327,6 +354,7 @@ const releaseFields = releaseMetadataFields(); const metadata = { ...releaseFields, channel: releaseChannel, + ...controlBlock, expectedPlatforms: expectedTargets, expectedTargets, failedPlatforms: failedTargets, diff --git a/tools/release/src/storage/summary-metadata.ts b/tools/release/src/storage/summary-metadata.ts index 43b80b33b3..9a1ef3ffb5 100644 --- a/tools/release/src/storage/summary-metadata.ts +++ b/tools/release/src/storage/summary-metadata.ts @@ -25,17 +25,22 @@ const metadata = (metadataPath.length > 0 } return response.json(); })()) as { + control?: { launcher?: { version?: { min?: string; url?: string } } }; readyTargets?: string[]; releaseState?: string; r2?: { versionMetadataUrl?: string }; }; +const launcherVersionMin = metadata.control?.launcher?.version?.min; writeText(summaryPath, [ `## ${releaseChannel[0]?.toUpperCase() ?? ""}${releaseChannel.slice(1)} release metadata`, "", `- version: \`${versionFromMetadata(metadata as Record)}\``, `- state: \`${metadata.releaseState ?? ""}\``, `- ready targets: \`${(metadata.readyTargets ?? []).join(", ")}\``, + ...(launcherVersionMin == null + ? [] + : [`- launcher version floor: \`${launcherVersionMin}\` (forces installer reinstall below this outer version)`]), `- metadata: ${metadata.r2?.versionMetadataUrl ?? metadataUrl}`, ].join("\n")); diff --git a/tools/release/src/storage/verify-metadata.ts b/tools/release/src/storage/verify-metadata.ts index e49ec74466..1eef1dc85b 100644 --- a/tools/release/src/storage/verify-metadata.ts +++ b/tools/release/src/storage/verify-metadata.ts @@ -1,4 +1,8 @@ import { optional, required } from "./common.ts"; +import { + assertLauncherVersionFloorSatisfiable, + resolveLauncherVersionFloor, +} from "./launcher-version-floor.ts"; import { releaseChannelDescriptor } from "@open-design/release"; import { readFile } from "node:fs/promises"; import { parseReleaseNotePublication, releaseNoteMetadataFromPublication } from "../release-note/publication.ts"; @@ -23,6 +27,7 @@ const metadata = (metadataPath.length > 0 return response.json(); })()) as { channel?: string; + control?: { launcher?: { version?: { min?: string; url?: string } } }; releaseState?: string; releaseTargets?: Record; status?: string }>; [key: string]: unknown; @@ -37,6 +42,31 @@ if (metadata[versionField] !== releaseVersion) { throw new Error(`metadata ${versionField} mismatch: expected ${releaseVersion}, got ${String(metadata[versionField])}`); } +// The published control.launcher.version block must match the channel policy +// resolved from the same repo-vars pairs the publish step consumed; unknown +// fields would otherwise pass silently. +const expectedLauncherVersionFloor = resolveLauncherVersionFloor(releaseChannel); +if (expectedLauncherVersionFloor != null) { + assertLauncherVersionFloorSatisfiable(expectedLauncherVersionFloor, releaseVersion); +} +const publishedControlVersion = metadata.control?.launcher?.version; +if (expectedLauncherVersionFloor == null) { + if (publishedControlVersion != null) { + throw new Error("metadata unexpectedly contains a control.launcher.version block"); + } +} else { + if (publishedControlVersion?.min !== expectedLauncherVersionFloor.min) { + throw new Error( + `metadata control.launcher.version.min mismatch: expected ${expectedLauncherVersionFloor.min}, got ${String(publishedControlVersion?.min)}`, + ); + } + if (publishedControlVersion.url !== expectedLauncherVersionFloor.url) { + throw new Error( + `metadata control.launcher.version.url mismatch: expected ${String(expectedLauncherVersionFloor.url)}, got ${String(publishedControlVersion.url)}`, + ); + } +} + if (releaseNoteManifestPath.length === 0) { if (releaseChannel === "stable") { throw new Error("RELEASE_NOTE_MANIFEST_PATH is required to verify stable metadata"); diff --git a/tools/release/tests/channel-prepare.test.ts b/tools/release/tests/channel-prepare.test.ts index b8b8f5b7d8..82234b3bcd 100644 --- a/tools/release/tests/channel-prepare.test.ts +++ b/tools/release/tests/channel-prepare.test.ts @@ -140,6 +140,29 @@ async function writeFakeGhScript(root: string): Promise { return scriptPath; } +/** + * Hermetic stand-in for the repository's `open-design-v*` tags. The prepare + * scripts derive the latest-stable floor from `git tag --list`, so without + * this the tests depend on whatever tags the local clone happens to have — + * green on tagless CI checkouts, permanently red on any developer clone once + * real stable tags advance past main's package version. `GIT_DIR` redirects + * the spawned scripts' git to a throwaway repo holding exactly the tags the + * test declares. + */ +async function createHermeticTagRepoEnv(stableTags: string[]): Promise> { + const gitRoot = await mkdtemp(join(tmpdir(), "od-tools-release-tags-")); + await execFileAsync("git", ["init", "--quiet"], { cwd: gitRoot }); + await execFileAsync( + "git", + ["-c", "user.email=channel-prepare@test", "-c", "user.name=channel-prepare", "commit", "--allow-empty", "--quiet", "-m", "seed"], + { cwd: gitRoot }, + ); + for (const tag of stableTags) { + await execFileAsync("git", ["tag", tag], { cwd: gitRoot }); + } + return { GIT_DIR: join(gitRoot, ".git") }; +} + async function readPackagedVersion(): Promise { const packageJson = JSON.parse(await readFile(packagedPackageJsonPath, "utf8")) as { version?: unknown }; if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { @@ -245,6 +268,9 @@ describe("tools-release local channel prepare validation", () => { OPEN_DESIGN_GH_NODE_SCRIPT: fakeGh, OPEN_DESIGN_STABLE_METADATA_URL: `${server.origin}/stable/latest/metadata.json`, OPEN_DESIGN_STABLE_VERSION: packagedVersion, + // Matches the stable fixture metadata above and keeps the tag-derived + // latest-stable floor below any real packaged version. + ...(await createHermeticTagRepoEnv(["open-design-v0.9.0"])), }; const beta = await runPrepare("beta", { @@ -336,6 +362,7 @@ describe("tools-release local channel prepare validation", () => { GITHUB_REPOSITORY: "nexu-io/open-design", GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567", OPEN_DESIGN_PREVIEW_METADATA_URL: `${server.origin}/preview/latest/metadata.json`, + ...(await createHermeticTagRepoEnv(["open-design-v0.10.0"])), }); expect(preview.stdout).toContain(`[release-preview] base version: ${packagedVersion}`); @@ -345,6 +372,27 @@ describe("tools-release local channel prepare validation", () => { } }); + it("rejects a preview prepare whose packaged version does not clear the latest stable tag", async () => { + const objects: Record = { + "preview/latest/metadata.json": countedMetadata("preview", "0.10.0-preview.2", 2), + }; + const server = await startMetadataServer(objects); + + try { + await expect( + runPrepare("preview", { + GITHUB_REF_NAME: "main", + GITHUB_REPOSITORY: "nexu-io/open-design", + GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567", + OPEN_DESIGN_PREVIEW_METADATA_URL: `${server.origin}/preview/latest/metadata.json`, + ...(await createHermeticTagRepoEnv(["open-design-v99.0.0"])), + }), + ).rejects.toThrow(/must be strictly greater than latest stable 99\.0\.0/); + } finally { + await server.close(); + } + }); + it("treats a 403 betas latest response as a cold-start missing metadata object", async () => { const packagedVersion = await readPackagedVersion(); const objects: Record = { diff --git a/tools/release/tests/launcher-version-floor.test.ts b/tools/release/tests/launcher-version-floor.test.ts new file mode 100644 index 0000000000..c54c996c71 --- /dev/null +++ b/tools/release/tests/launcher-version-floor.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + assertLauncherVersionFloorSatisfiable, + resolveLauncherVersionFloor, +} from "../src/storage/launcher-version-floor.js"; + +describe("launcher version floor channel policy", () => { + it("resolves the channel-specific pair when the channel min is set", () => { + const floor = resolveLauncherVersionFloor("beta", { + RELEASE_LAUNCHER_VERSION_MIN_BETA: "1.2.0-beta.3", + RELEASE_LAUNCHER_VERSION_MIN_URL_BETA: "https://example.test/beta-help", + RELEASE_LAUNCHER_VERSION_MIN_STABLE: "1.1.0", + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: "https://example.test/stable-help", + }); + expect(floor).toEqual({ min: "1.2.0-beta.3", url: "https://example.test/beta-help" }); + }); + + it("falls back to the stable pair as a unit when the channel pair is unset", () => { + // Pair-level fallback: a channel never mixes its own fields with stable's — + // channel policy is one coherent pair. + const floor = resolveLauncherVersionFloor("preview", { + RELEASE_LAUNCHER_VERSION_MIN_STABLE: "1.1.0", + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: "https://example.test/stable-help", + }); + expect(floor).toEqual({ min: "1.1.0", url: "https://example.test/stable-help" }); + }); + + it("rejects an orphan channel url instead of silently falling back", () => { + // An operator who set the channel url but not its min made a + // misconfiguration; hiding it behind the stable fallback would silently + // drop their intent. + expect(() => + resolveLauncherVersionFloor("preview", { + RELEASE_LAUNCHER_VERSION_MIN_URL_PREVIEW: "https://example.test/preview-only-url", + RELEASE_LAUNCHER_VERSION_MIN_STABLE: "1.1.0", + }), + ).toThrow(/URL_PREVIEW requires RELEASE_LAUNCHER_VERSION_MIN_PREVIEW/); + }); + + it("resolves stable from its own pair only", () => { + expect( + resolveLauncherVersionFloor("stable", { + RELEASE_LAUNCHER_VERSION_MIN_STABLE: "1.1.0", + }), + ).toEqual({ min: "1.1.0" }); + }); + + it("returns null when neither the channel nor stable defines a floor", () => { + expect(resolveLauncherVersionFloor("betas", {})).toBeNull(); + expect(resolveLauncherVersionFloor("stable", {})).toBeNull(); + }); + + it("treats empty-string vars as unset (GitHub passes unset vars as empty)", () => { + expect( + resolveLauncherVersionFloor("beta", { + RELEASE_LAUNCHER_VERSION_MIN_BETA: "", + RELEASE_LAUNCHER_VERSION_MIN_STABLE: "", + }), + ).toBeNull(); + }); + + it("rejects a channel url without a channel min at the source pair", () => { + expect(() => + resolveLauncherVersionFloor("stable", { + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: "https://example.test/orphan", + }), + ).toThrow(/URL_STABLE requires RELEASE_LAUNCHER_VERSION_MIN_STABLE/); + }); + + it("rejects malformed versions and non-http urls", () => { + expect(() => + resolveLauncherVersionFloor("beta", { RELEASE_LAUNCHER_VERSION_MIN_BETA: "not-a-version" }), + ).toThrow(/not a valid version/); + expect(() => + resolveLauncherVersionFloor("beta", { + RELEASE_LAUNCHER_VERSION_MIN_BETA: "1.0.0", + RELEASE_LAUNCHER_VERSION_MIN_URL_BETA: "ftp://example.test/help", + }), + ).toThrow(/http\(s\) URL/); + }); + + it("rejects a floor above the release version", () => { + expect(() => + assertLauncherVersionFloorSatisfiable({ min: "2.0.0" }, "1.2.3-beta.4"), + ).toThrow(/exceeds release version/); + expect(() => + assertLauncherVersionFloorSatisfiable({ min: "1.2.3-beta.4" }, "1.2.3-beta.4"), + ).not.toThrow(); + }); +}); diff --git a/tools/serve/src/index.ts b/tools/serve/src/index.ts index 91d0ae590b..4b669a8904 100644 --- a/tools/serve/src/index.ts +++ b/tools/serve/src/index.ts @@ -7,6 +7,8 @@ import { startUpdaterFixtureServer } from "./updater-fixture.js"; type CliOptions = { artifactPath?: string; channel?: ReleaseChannel; + controlLauncherVersionMin?: string; + controlLauncherVersionUrl?: string; host?: string; json?: boolean; platform?: "mac" | "win"; @@ -59,6 +61,8 @@ async function start(service: string, options: CliOptions): Promise { const server = await startUpdaterFixtureServer({ artifactPath: options.artifactPath, channel: options.channel, + controlLauncherVersionMin: options.controlLauncherVersionMin, + controlLauncherVersionUrl: options.controlLauncherVersionUrl, host: options.host, platform: parsePlatform(options.platform), includePayload: options.includePayload, @@ -94,6 +98,8 @@ cli .command("start ", "Start a local fixture service") .option("--artifact-path ", "Serve a local update artifact file") .option("--channel ", "Updater channel: stable|beta|betas|prerelease|preview", { default: "stable" }) + .option("--control-launcher-version-min ", "Publish control.launcher.version.min in fixture metadata") + .option("--control-launcher-version-url ", "Publish control.launcher.version.url in fixture metadata") .option("--host ", "Host to bind", { default: "127.0.0.1" }) .option("--json", "Print JSON") .option("--include-payload", "Include launcher payload metadata") diff --git a/tools/serve/src/updater-fixture.ts b/tools/serve/src/updater-fixture.ts index 589b5f6715..e4e4b76984 100644 --- a/tools/serve/src/updater-fixture.ts +++ b/tools/serve/src/updater-fixture.ts @@ -18,6 +18,7 @@ export type UpdaterFixtureOptions = { artifactPath?: string; channel?: UpdaterFixtureChannel; controlLauncherVersionMin?: string; + controlLauncherVersionUrl?: string; host?: string; includePayload?: boolean; launcherSchema?: number; @@ -268,8 +269,17 @@ export async function startUpdaterFixtureServer(options: UpdaterFixtureOptions = generatedAt: new Date().toISOString(), ...channelMetadata(channel, version), ...(options.launcherSchema != null ? { launcher: { schema: options.launcherSchema } } : {}), - ...(options.controlLauncherVersionMin != null - ? { control: { launcher: { version: { min: options.controlLauncherVersionMin } } } } + ...(options.controlLauncherVersionMin != null || options.controlLauncherVersionUrl != null + ? { + control: { + launcher: { + version: { + ...(options.controlLauncherVersionMin != null ? { min: options.controlLauncherVersionMin } : {}), + ...(options.controlLauncherVersionUrl != null ? { url: options.controlLauncherVersionUrl } : {}), + }, + }, + }, + } : {}), platforms: { [platformKey]: { diff --git a/tools/serve/tests/release-metadata-publish.test.ts b/tools/serve/tests/release-metadata-publish.test.ts index 91d4ba1a9a..0db9f2747c 100644 --- a/tools/serve/tests/release-metadata-publish.test.ts +++ b/tools/serve/tests/release-metadata-publish.test.ts @@ -140,6 +140,18 @@ describe("shared release metadata publisher", () => { STATE_SOURCE: "local-tools-serve", WIN_X64_RESULT: "success", ...(channel === "beta" ? { RELEASE_LATEST_CAS_REQUIRED: "true" } : {}), + // The launcher version floor rides through publish + verify on one + // channel via its channel-suffixed repo-vars pair; the others must + // publish without a control block (their pairs and the stable + // fallback pair stay unset). + RELEASE_LAUNCHER_VERSION_MIN_STABLE: "", + RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE: "", + ...(channel === "beta" + ? { + RELEASE_LAUNCHER_VERSION_MIN_BETA: "1.2.3-beta.4", + RELEASE_LAUNCHER_VERSION_MIN_URL_BETA: "https://example.test/reinstall-help", + } + : {}), }; await runNode(["--experimental-strip-types", "tools/release/src/release-note/prepare.ts"], { cwd: repoRoot, @@ -164,6 +176,7 @@ describe("shared release metadata publisher", () => { const metadata = JSON.parse(await readFile(join(metadataDir, "metadata.json"), "utf8")) as { channel?: string; + control?: { launcher?: { version?: { min?: string; url?: string } } }; releaseState?: string; releaseTargets?: { mac_arm64?: { artifacts?: { payload?: { url?: string } } }; @@ -198,6 +211,14 @@ describe("shared release metadata publisher", () => { if (channel === "stable") { expect(metadata.stableVersion).toBe("1.2.3"); } + if (channel === "beta") { + expect(metadata.control?.launcher?.version).toEqual({ + min: "1.2.3-beta.4", + url: "https://example.test/reinstall-help", + }); + } else { + expect(metadata.control).toBeUndefined(); + } expect(server.getObject(`${channel}/latest/metadata.json`)).not.toBeNull(); expect(server.getObject(`${channel}/versions/${version}/release-notes/en.md`)?.toString("utf8")).toContain( `title: Open Design ${version}`, @@ -224,6 +245,28 @@ describe("shared release metadata publisher", () => { } }); + it("rejects a launcher version floor above the release version", async () => { + // A floor the published release cannot satisfy would make the updater's + // same-version reinstall offer nag forever; publication must refuse it. + const repoRoot = resolve(import.meta.dirname, "../../.."); + const root = await mkdtemp(join(tmpdir(), "od-release-metadata-floor-")); + await expect(runNode(["--experimental-strip-types", "tools/release/src/storage/publish-metadata.ts"], { + cwd: repoRoot, + env: { + ...process.env, + RELEASE_CHANNEL: "beta", + RELEASE_LAUNCHER_VERSION_MIN_BETA: "9.9.9", + RELEASE_MANIFEST_DIR: root, + RELEASE_METADATA_DIR: root, + RELEASE_OUTPUTS_PATH: join(root, "outputs.json"), + RELEASE_PUBLIC_ORIGIN: "https://releases.example.test", + RELEASE_PUBLISH_SIDE_EFFECTS: "false", + RELEASE_VERSION: "1.2.3-beta.4", + STATE_SOURCE: "local-tools-serve", + }, + })).rejects.toThrow(/exceeds release version/); + }); + it("builds planned release-note and metadata artifacts without storage access in dry-run mode", async () => { const repoRoot = resolve(import.meta.dirname, "../../.."); const root = await mkdtemp(join(tmpdir(), "od-release-metadata-dry-run-")); diff --git a/tools/serve/tests/updater-fixture.test.ts b/tools/serve/tests/updater-fixture.test.ts index f8f1f7f157..f46ff87d67 100644 --- a/tools/serve/tests/updater-fixture.test.ts +++ b/tools/serve/tests/updater-fixture.test.ts @@ -43,6 +43,39 @@ describe("updater fixture server", () => { } }); + it("publishes the control.launcher.version block from fixture knobs", async () => { + const server = await startUpdaterFixtureServer({ + artifactBody: "fixture artifact", + channel: "beta", + controlLauncherVersionMin: "1.5.0-beta.1", + controlLauncherVersionUrl: "https://example.com/reinstall-help", + version: "2.0.0-beta.1", + }); + try { + const metadata = await (await fetch(server.info.metadataUrl)).json() as { + control?: { launcher?: { version?: { min?: string; url?: string } } }; + }; + expect(metadata.control?.launcher?.version?.min).toBe("1.5.0-beta.1"); + expect(metadata.control?.launcher?.version?.url).toBe("https://example.com/reinstall-help"); + } finally { + await server.close(); + } + }); + + it("omits the control block when no control knobs are set", async () => { + const server = await startUpdaterFixtureServer({ + artifactBody: "fixture artifact", + channel: "beta", + version: "2.0.0-beta.1", + }); + try { + const metadata = await (await fetch(server.info.metadataUrl)).json() as { control?: unknown }; + expect(metadata.control).toBeUndefined(); + } finally { + await server.close(); + } + }); + it("serves Windows installer metadata for the updater flow", async () => { const server = await startUpdaterFixtureServer({ artifactBody: "fixture installer",