From a2ba3ee2ad05b88457bbbd1b421ecc19fd1ca326 Mon Sep 17 00:00:00 2001 From: PerishFire <39043006+PerishCode@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:18:48 +0800 Subject: [PATCH] feat(updater): manual cache clear, installer-reinstall floor, and crash-recovery lifecycle (#6032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(updater): manual cache clear + installer-reinstall floor on the installed outer version Two disaster-recovery capabilities for the packaged updater: - Reuse control.launcher.version.min properly: the gate now compares 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 for tests), not the running payload version. When the floor trips - or min is set but the outer version is unreadable (conservative) - the updater selects the installer artifact and offers a same-version installer reinstall when no newer release exists (clamped to min <= candidate so the offer always converges). The status snapshot carries a reinstall block {reason, installedVersion, minVersion, url}; UpdateDialog/UpdaterPopup render reinstall copy with an optional operator jump link. tools-release publish-metadata publishes the block from RELEASE_LAUNCHER_VERSION_MIN(_URL) (per-channel repo vars with a _STABLE fallback wired in all release workflows) and hard-fails when min exceeds the release version; verify-metadata re-checks the published block and summary-metadata surfaces the floor. - Manual clear-cache action (od:update:clear-cache IPC, sidecar clear-cache action, Settings > About row with a two-stage inline confirm): resets one-shot update state (downloaded release, install freeze) back to idle, purges releases/staging/downloads/.back, removes a stale launcher attempt.json plus any non-confirmed desktop-handoff journal, and deletes non-retained payload versions. Runtime active/lastSuccessful versions, retained cleanup entries, install.json, and a confirmed handoff journal are never touched; locked files defer through the existing cleanup.json retry machinery. Also: tools-pack --update-action now validates through sidecar-proto's exported isDesktopUpdateAction so clear-cache reaches the ops surface; tools-serve updater fixture gains control min/url knobs and CLI flags; 9 new i18n keys across all 19 locales; tools/pack/AGENTS.md documents the gate semantics and clear-cache boundaries. Validated locally per the updater matrix (desktop/web/tools-serve/tools-pack suites, guard, typechecks) plus a mac release-beta closed loop: platform full spec, same-version reinstall against a real outer/payload version split, clear-cache recovery, and a real-feed payload update to 0.15.1-beta.4. * test(updater): close Windows recovery acceptance * test(updater): mirror Windows same-version reinstall recovery on the mac lane Port the Windows runSameVersionUpdaterRecoveryAcceptance shape into the mac full profile: after the payload cold-start acceptance, restart the packaged app against a fixture publishing an installed-outer floor at the running payload version and assert the same-version installer reinstall snapshot (reason/installedVersion/minVersion/url), the popup reinstall learn-more link, clear-cache back to idle with launcher pointers untouched, explicit re-check recovery, and the installer open in dry-run mode — macOS has no silent DMG install to execute, unlike the Windows NSIS transaction lane. Extends the popup eval expression/validator with reinstallLinkVisible and records an updaterRecovery block in the smoke summary. Full-profile test timeout raised 240s -> 360s for the extra app restart. Validated locally: release-smoke full profile green in 80s with the recovery summary populated (reinstall block exact, cleared idle, recovered dmg, dryRun install). * refactor(release): centralize launcher version floor channel policy in one resolver Channel policy for control.launcher.version.{min,url} is now managed as one repo-vars pair per channel (RELEASE_LAUNCHER_VERSION_MIN[_URL]_). Workflows pass the channel pair plus the STABLE pair through verbatim — the || fallback expressions are gone from YAML — and the shared resolver in tools/release/src/storage/launcher-version-floor.ts owns resolution and validation: pair-level stable fallback for non-stable channels (never mixing one channel's min with another's url), version format, http(s) url, orphan url rejection, and the floor <= releaseVersion guard. publish-metadata and verify-metadata consume the same resolver, so verify re-derives the expected block from the same channel policy instead of trusting a pre-resolved env. release-workflows.test.ts pins the verbatim passthrough keys in all five release lanes and rejects any reintroduced YAML fallback. * feat(updater): let clear-cache rebuild a provably-owned corrupt update store A corrupt store is one of the blocking scenarios the manual clear exists to recover from, yet the action previously bailed on the very store errors it should heal. clear-cache now rebuilds the update root when — and only when — ownership is provable: the current-generation sentinel is the proof, and everything else inside an owned root is updater cache by definition. Roots without a sentinel, with a foreign-generation marker, or failing IO are never touched; the original store error surfaces unchanged. Four specs pin the boundary (corrupt metadata rebuilt, stray entries rebuilt, unowned root refused with files intact, foreign-generation marker refused). * fix(launcher): pre-arm delegated payload launches so pre-bookkeeping crashes roll back A payload that spawned successfully but died before reaching its own launcher bookkeeping left no attempt.json behind, so every later cold start re-selected the same broken active pointer and re-delegated to the dead payload — a permanent dead loop the installer fallback could never surface (found while designing the crash-rollback acceptance). The delegating parent now arms attempt.json BEFORE spawning the payload and passes the pointer as --od-launcher-delegated-* argv. Selection gains an active-delegated reason: an attempt matching active AND the delegated pointer is the launch in progress, not a previous failure; only an exact active match qualifies, so a stale pointer cannot defeat the rollback. Rollback (last-successful) delegations deliberately carry no marker and never re-arm — the attempt on disk IS the rollback evidence the child must re-derive. Covered end to end: packaged outer delegation pre-arms (and skips pre-arm on rollback), the updater's payload activation pre-arms and threads the pointer through the deferred relaunch, and confirm clears the attempt for delegated launches. launcher-proto 18, packaged 237, desktop 264 all green. * fix(updater): scrub a stale payload install freeze after a rollback A payload install freezes the updater with installResult.activeVersion awaiting the relaunch. When that payload crashed and the launcher rolled back, the app restarts on the OLD version with the freeze still persisted — every later check became a frozen no-op, so a rolled-back install never saw another update offer (the crash-rollback acceptance exposed this in its self-heal phase). Cold-start restore now detects the broken promise — installResult.activeVersion newer than the running version on a live process — and drops the freeze and stale install result while keeping the verified downloaded release actionable. The installer-artifact freeze (no activeVersion) keeps its existing restore semantics. Red spec: 'clears a stale payload install freeze when the promised relaunch never became the running version'. * test(updater): full-lifecycle acceptance — silent startup, crash rollback, coverage map Two new packaged acceptance tests on both platform lanes (mac validated locally; win symmetric for the Windows box): - 'applies a downloaded payload silently on the next cold start': enables the daemon-owned allowSilentUpdates through the production GET+PUT /api/app-config path, restarts, and asserts the first scheduler tick installs, quits, and relaunches with converged pointers — no updater action issued. - 'rolls back a crashing payload and self-heals on the next good update': repacks the real payload archive with a crash-before-bookkeeping stub (mac: shebang script via ditto; win: a real executable — System32 where.exe — via the installed app's bundled 7z, since a script cannot stand in for an exe), drives the update through the popup, asserts the pre-armed attempt survives the crash as rollback evidence, cold-starts into the rolled-back base version (degraded steady state pinned: broken pointer stays active with its attempt), then self-heals via a version-bumped healthy payload — real recovery releases ship as version+1. Spec hygiene: resetPackagedRuntimeState wipes launcher/runtime/daemon state before each lifecycle test (uninstall keeps runtime data by design, and leftover pointers silently delegate fresh installs onto stale payloads); mac waitForHealthyDesktopVersion gains win's requireSettledLauncher opt-out for rollback waits. docs/testing/updater-lifecycle.md is the new lifecycle-to-test coverage map (incl. deliberate manual-only nodes), linked from tools/pack/AGENTS.md and the e2e coverage status doc. * fix(updater): harden Windows full-lifecycle acceptance * test(release): make channel-prepare hermetic against local git tags prepare-preview derives its latest-stable floor from the clone's real open-design-v* tags, so the channel-prepare tests were green on tagless CI checkouts but permanently red on any developer clone once shipped stable tags advanced past main's package version (0.15.1 vs v0.15.1/v0.16.0 today). The spawned scripts now run with GIT_DIR pointing at a throwaway repo holding exactly the tags each test declares, and a new negative case pins the floor rejection itself (packaged version below a v99.0.0 stable tag). * test(e2e): model delegated payload launch Pass the updater's pre-armed payload pointer into the cross-runtime launcher simulation so it represents the spawned payload process instead of a later crash-recovery launch. Generated-By: looper 0.11.0 (runner=fixer, agent=codex) * test(web): wait for question form readiness Wait for the asynchronously derived submit-disabled state before asserting it, removing the suite-order race observed by the Web workspace tests check. Generated-By: looper 0.11.0 (runner=fixer, agent=codex) * fix(updater): preserve recovery state across locks and restarts Generated-By: looper 0.11.0 (runner=fixer, agent=codex) --------- Co-authored-by: Looper --- .github/workflows/release-beta-s.yml | 4 + .github/workflows/release-beta.yml | 8 + .github/workflows/release-prerelease.yml | 8 + .github/workflows/release-preview.yml | 8 + .github/workflows/release-stable.yml | 4 + apps/desktop/src/main/preload.cts | 4 +- apps/desktop/src/main/runtime.ts | 6 + apps/desktop/src/main/updater.ts | 501 +++++++++++-- .../tests/main/preload-host-boundary.test.ts | 1 + .../tests/main/updater-host-boundary.test.ts | 1 + apps/desktop/tests/main/updater.test.ts | 627 +++++++++++++++- apps/packaged/src/index.ts | 3 + apps/packaged/src/launcher-runtime.ts | 33 +- apps/packaged/src/payload-desktop-launch.ts | 15 +- apps/packaged/src/sidecars.ts | 36 + .../tests/payload-desktop-launch.test.ts | 159 +++- apps/packaged/tests/sidecars.test.ts | 20 + apps/web/src/components/SettingsDialog.tsx | 61 ++ apps/web/src/components/UpdateDialog.tsx | 33 +- apps/web/src/components/UpdaterPopup.tsx | 20 + apps/web/src/i18n/locales/ar.ts | 9 + apps/web/src/i18n/locales/de.ts | 9 + apps/web/src/i18n/locales/en.ts | 9 + apps/web/src/i18n/locales/es-ES.ts | 9 + apps/web/src/i18n/locales/fa.ts | 9 + apps/web/src/i18n/locales/fr.ts | 9 + apps/web/src/i18n/locales/hu.ts | 9 + apps/web/src/i18n/locales/id.ts | 9 + apps/web/src/i18n/locales/it.ts | 9 + apps/web/src/i18n/locales/ja.ts | 9 + apps/web/src/i18n/locales/ko.ts | 9 + apps/web/src/i18n/locales/pl.ts | 9 + apps/web/src/i18n/locales/pt-BR.ts | 9 + apps/web/src/i18n/locales/ru.ts | 9 + apps/web/src/i18n/locales/th.ts | 9 + apps/web/src/i18n/locales/tr.ts | 9 + apps/web/src/i18n/locales/uk.ts | 9 + apps/web/src/i18n/locales/zh-CN.ts | 9 + apps/web/src/i18n/locales/zh-TW.ts | 9 + apps/web/src/i18n/types.ts | 12 + apps/web/src/lib/updater.ts | 13 + .../web/src/styles/workspace/mention-home.css | 19 + .../ProjectView.deleteConversation.test.tsx | 2 +- .../SettingsDialog.execution.test.tsx | 70 ++ .../tests/components/UpdateDialog.test.tsx | 42 ++ .../tests/components/UpdaterPopup.test.tsx | 49 ++ apps/web/tests/lib/updater.test.ts | 27 + docs/testing/e2e-coverage/status.md | 3 + docs/testing/updater-lifecycle.md | 94 +++ e2e/lib/vitest/tools-serve-updater-fixture.ts | 10 + e2e/specs/mac.spec.ts | 485 +++++++++++- e2e/specs/win.spec.ts | 695 +++++++++++++++++- .../packaged-launcher-update-loop.test.ts | 6 +- e2e/tests/packaged-smoke-workflow.test.ts | 10 + e2e/tests/updater-fixture.test.ts | 53 ++ packages/host/src/actions.ts | 8 + packages/host/src/index.ts | 3 + packages/host/src/protocol.ts | 23 +- packages/host/src/testing.ts | 1 + packages/launcher-proto/src/index.ts | 50 ++ packages/launcher-proto/tests/index.test.ts | 64 ++ packages/sidecar-proto/src/index.ts | 30 +- pnpm-lock.yaml | 3 + tools/pack/AGENTS.md | 4 +- tools/pack/src/index.ts | 2 +- tools/pack/src/mac/lifecycle.ts | 8 +- tools/pack/src/win/lifecycle.ts | 10 +- tools/pack/tests/release-workflows.test.ts | 38 + tools/pack/tests/win-lifecycle.test.ts | 56 +- tools/release/package.json | 1 + tools/release/scripts/build-platform.ps1 | 10 +- .../src/storage/launcher-version-floor.ts | 77 ++ tools/release/src/storage/publish-metadata.ts | 28 + tools/release/src/storage/summary-metadata.ts | 5 + tools/release/src/storage/verify-metadata.ts | 30 + tools/release/tests/channel-prepare.test.ts | 48 ++ .../tests/launcher-version-floor.test.ts | 91 +++ tools/serve/src/index.ts | 6 + tools/serve/src/updater-fixture.ts | 14 +- .../tests/release-metadata-publish.test.ts | 43 ++ tools/serve/tests/updater-fixture.test.ts | 33 + 81 files changed, 3855 insertions(+), 144 deletions(-) create mode 100644 docs/testing/updater-lifecycle.md create mode 100644 e2e/tests/updater-fixture.test.ts create mode 100644 tools/release/src/storage/launcher-version-floor.ts create mode 100644 tools/release/tests/launcher-version-floor.test.ts 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",