From 9e90b86afa60aeeef05c145653f13cd2eed93896 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 4 Sep 2026 22:17:39 +0200 Subject: [PATCH] Restore reliable macOS desktop updates (#4322) * fix(desktop): restore macOS app updates Electron 41's Squirrel helper can leave the update handoff dormant on current macOS, so Paseo quits cleanly but relaunches the old bundle. Electron 44 wakes ShipIt through its XPC service. Include updater lifecycle events and ShipIt state in app diagnostics so future handoff failures identify the current version, target version, cache paths, and installer output. * fix(desktop): harden updater diagnostics Keep each ShipIt file's evidence available when another read fails, carry the service-selected version into updater lifecycle logs, and make clean workspace installs resolve the Electron runtime used by desktop tests. * refactor(desktop): name updater install options * refactor(desktop): name updater log payloads * refactor(desktop): name clipboard payload * fix(desktop): surface updater lookup failures * test(desktop): await guest focus transition * fix(desktop): guard macOS update compatibility * test(release): validate desktop manifests before publish * test(desktop): await populated daemon pid file --- .github/workflows/ci.yml | 23 -- .github/workflows/desktop-release.yml | 26 +-- docs/release.md | 10 + nix/npm-deps.hash | 2 +- package-lock.json | 196 ++++-------------- package.json | 1 + .../app/src/desktop/daemon/desktop-daemon.ts | 53 +++++ .../desktop-diagnostic-report.test.ts | 56 ++++- .../diagnostics/desktop-diagnostic-report.ts | 59 +++++- packages/desktop/e2e/browser-tabs.e2e.mjs | 21 +- packages/desktop/e2e/packaged-app-smoke.js | 5 +- packages/desktop/electron-builder.yml | 1 + packages/desktop/package.json | 2 +- .../desktop/src/daemon/daemon-manager.test.ts | 9 + packages/desktop/src/daemon/daemon-manager.ts | 2 + .../src/daemon/desktop-packaging.test.ts | 16 ++ .../desktop/src/diagnostics/updater.test.ts | 141 +++++++++++++ packages/desktop/src/diagnostics/updater.ts | 148 +++++++++++++ .../src/features/app-update-service.test.ts | 20 +- .../src/features/app-update-service.ts | 32 ++- .../desktop/src/features/auto-updater.test.ts | 45 ++++ packages/desktop/src/features/auto-updater.ts | 79 ++++++- .../src/features/browser-capture.test.ts | 19 +- .../desktop/src/features/browser-capture.ts | 19 +- .../src/features/browser-profile.test.ts | 2 +- .../desktop/src/features/browser-profile.ts | 2 +- packages/desktop/src/main.ts | 26 ++- packages/website/src/routes/download.tsx | 11 +- scripts/merge-mac-manifest.mjs | 9 +- scripts/merge-mac-manifest.test.mjs | 1 + scripts/validate-desktop-manifests.mjs | 65 ++++++ scripts/validate-desktop-manifests.test.mjs | 70 +++++++ 32 files changed, 922 insertions(+), 249 deletions(-) create mode 100644 packages/desktop/src/diagnostics/updater.test.ts create mode 100644 packages/desktop/src/diagnostics/updater.ts create mode 100644 scripts/validate-desktop-manifests.mjs create mode 100644 scripts/validate-desktop-manifests.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69689e9c13..c555a61432 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,8 +59,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -80,8 +78,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -107,8 +103,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -137,8 +131,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: &server_test_steps - uses: actions/checkout@v4 with: @@ -180,8 +172,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: windows-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: *server_test_steps desktop-tests-ubuntu: @@ -268,8 +258,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -296,8 +284,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -330,7 +316,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "1/4" PLAYWRIGHT_ARTIFACT: "1" steps: &playwright_test_steps @@ -378,7 +363,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "2/4" PLAYWRIGHT_ARTIFACT: "2" steps: *playwright_test_steps @@ -389,7 +373,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "3/4" PLAYWRIGHT_ARTIFACT: "3" steps: *playwright_test_steps @@ -400,7 +383,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "4/4" PLAYWRIGHT_ARTIFACT: "4" steps: *playwright_test_steps @@ -410,8 +392,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -435,7 +415,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" @@ -467,7 +446,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" @@ -481,7 +459,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 89f2f83ae6..5c9bc567ba 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -547,28 +547,10 @@ jobs: fi timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" - # shellcheck disable=SC2016 - ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e ' - const yaml = require("js-yaml"); - const fs = require("fs"); - const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED); - const expectedDate = process.env.RELEASE_DATE_EXPECTED; - if (!Number.isFinite(expectedHours) || expectedHours < 0) { - throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`); - } - for (const f of process.argv.slice(1)) { - const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; - if (m.rolloutHours !== expectedHours) { - throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`); - } - if (m.releaseDate !== expectedDate) { - throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`); - } - if (typeof m.version !== "string" || m.version.length === 0) { - throw new Error(`${f}: missing or invalid version`); - } - } - ' "${files[@]}" + node ../scripts/validate-desktop-manifests.mjs \ + --release-date "$timestamp" \ + --rollout-hours "$ROLLOUT_HOURS" \ + "${files[@]}" gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}" failed_builds=() diff --git a/docs/release.md b/docs/release.md index 6261d22980..342fa9eaf9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -253,6 +253,15 @@ If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its ow If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1. -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast. +### macOS system floor + +The desktop app requires macOS 13 or newer. Keep both release guards when the floor changes: + +- `packages/desktop/electron-builder.yml` writes the macOS version to `LSMinimumSystemVersion` for new installs. +- `scripts/merge-mac-manifest.mjs` writes the matching Darwin kernel version to `minimumSystemVersion` in the update manifest. Existing clients check this before downloading an update. + +macOS 13 maps to Darwin 22. The two values use different version domains; do not copy the macOS version into the update manifest. + ### Limitations - **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch. @@ -634,6 +643,7 @@ Each beta entry records what its testers receive. Promotion produces the single - [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml` +- [ ] `latest-mac.yml` contains the current `minimumSystemVersion` guard - [ ] GitHub `Android APK Release` workflow for the same tag is green - [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published - [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 511b1444e3..3324b8a3a6 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-1kiHsXpooSqC38MwZP2E7rQK+Zhuv0fUOxUHfnM30wA= +sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE= diff --git a/package-lock.json b/package-lock.json index a7daa8db3f..1e60b61b11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@vercel/nft": "^1.5.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", + "electron": "44.2.0", "get-port-cli": "^3.0.0", "js-yaml": "^4.1.1", "knip": "^5.82.1", @@ -2883,6 +2884,16 @@ "node": ">=0.8.0" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -2989,70 +3000,37 @@ } }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" + "undici": "^7.24.4" } }, - "node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@electron/get/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@electron/get/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@electron/notarize": { @@ -13187,17 +13165,6 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -15813,16 +15780,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -18675,22 +18632,22 @@ } }, "node_modules/electron": { - "version": "41.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-41.2.0.tgz", - "integrity": "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==", + "version": "44.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.2.0.tgz", + "integrity": "sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { @@ -18797,19 +18754,19 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/electron/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -21881,43 +21838,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -22082,16 +22002,6 @@ "walk-up-path": "^4.0.0" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -29636,13 +29546,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -36137,17 +36040,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -37642,7 +37534,7 @@ "devDependencies": { "@types/node": "24.6.0", "@types/ws": "^8.5.14", - "electron": "41.2.0", + "electron": "44.2.0", "electron-builder": "26.8.1", "typescript": "5.9.3", "unzip-crx-3": "^0.2.0", diff --git a/package.json b/package.json index 375e5f0b82..8a60b786db 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "@vercel/nft": "^1.5.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", + "electron": "44.2.0", "get-port-cli": "^3.0.0", "js-yaml": "^4.1.1", "knip": "^5.82.1", diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 83fd0864cf..f465c32ac1 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -34,6 +34,25 @@ export interface DesktopAppLogs { contents: string; } +export interface DesktopUpdaterDiagnosticFile { + path: string; + exists: boolean; + modifiedAt: string | null; + contents: string; + error: string | null; +} + +export interface DesktopUpdaterDiagnostics { + platform: string; + currentVersion: string; + targetVersion: string | null; + targetVersionError: string | null; + shipItDirectory: string | null; + state: DesktopUpdaterDiagnosticFile | null; + stdout: DesktopUpdaterDiagnosticFile | null; + stderr: DesktopUpdaterDiagnosticFile | null; +} + export interface LocalTransportTarget { [key: string]: unknown; transportType: "socket" | "pipe"; @@ -123,6 +142,36 @@ function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs { }; } +function parseDesktopUpdaterDiagnosticFile(raw: unknown): DesktopUpdaterDiagnosticFile | null { + if (raw === null) return null; + if (!isRecord(raw)) { + throw new Error("Unexpected desktop updater diagnostic file response."); + } + return { + path: toStringOrNull(raw.path) ?? "", + exists: raw.exists === true, + modifiedAt: toStringOrNull(raw.modifiedAt), + contents: typeof raw.contents === "string" ? raw.contents : "", + error: toStringOrNull(raw.error), + }; +} + +function parseDesktopUpdaterDiagnostics(raw: unknown): DesktopUpdaterDiagnostics { + if (!isRecord(raw)) { + throw new Error("Unexpected desktop updater diagnostics response."); + } + return { + platform: toStringOrNull(raw.platform) ?? "unknown", + currentVersion: toStringOrNull(raw.currentVersion) ?? "unknown", + targetVersion: toStringOrNull(raw.targetVersion), + targetVersionError: toStringOrNull(raw.targetVersionError), + shipItDirectory: toStringOrNull(raw.shipItDirectory), + state: parseDesktopUpdaterDiagnosticFile(raw.state), + stdout: parseDesktopUpdaterDiagnosticFile(raw.stdout), + stderr: parseDesktopUpdaterDiagnosticFile(raw.stderr), + }; +} + export function shouldUseDesktopDaemon(): boolean { return isElectronRuntime(); } @@ -160,6 +209,10 @@ export async function getDesktopAppLogs(): Promise { }; } +export async function getDesktopUpdaterDiagnostics(): Promise { + return parseDesktopUpdaterDiagnostics(await invokeDesktopCommand("desktop_update_diagnostics")); +} + export async function getCliDaemonStatus(): Promise { const raw = await invokeDesktopCommand("cli_daemon_status"); if (typeof raw !== "string") { diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts index e26e1e776f..1ffa37b950 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts @@ -25,6 +25,34 @@ function makeSources(): DesktopDiagnosticSources { logPath: "/logs/Paseo/main.log", contents: "[login-shell-env] start\n[login-shell-env] failed", }), + getUpdaterDiagnostics: async () => ({ + platform: "darwin", + currentVersion: "0.7.0", + targetVersion: "0.7.2", + targetVersionError: null, + shipItDirectory: "/cache/sh.paseo.desktop.ShipIt", + state: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipItState.plist", + exists: true, + modifiedAt: "2026-09-04T11:22:19.000Z", + contents: '{"launchAfterInstallation":true}', + error: null, + }, + stdout: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipIt_stdout.log", + exists: false, + modifiedAt: null, + contents: "", + error: null, + }, + stderr: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipIt_stderr.log", + exists: true, + modifiedAt: "2026-09-01T08:39:20.000Z", + contents: "Installation completed successfully", + error: null, + }, + }), }; } @@ -50,15 +78,33 @@ describe("desktop diagnostic report", () => { await appLogGate; return makeSources().getAppLogs(); }, + getUpdaterDiagnostics: async () => { + calls.push("updater"); + return makeSources().getUpdaterDiagnostics(); + }, }; const resultPromise = collectDesktopDiagnosticSections(sources); - expect(calls).toEqual(["status", "daemonLogs", "appLogs"]); + expect(calls).toEqual(["status", "daemonLogs", "appLogs", "updater"]); releaseAppLogs(); await expect(resultPromise).resolves.toMatchObject({ status: "done" }); }); + test("includes target version lookup errors", async () => { + const sources: DesktopDiagnosticSources = { + ...makeSources(), + getUpdaterDiagnostics: async () => ({ + ...(await makeSources().getUpdaterDiagnostics()), + targetVersion: null, + targetVersionError: "plutil failed", + }), + }; + + const result = await collectDesktopDiagnosticSections(sources); + expect(result.sections.join("\n\n")).toContain("Target version error: plutil failed"); + }); + test("includes the Electron main-process log after the daemon log", async () => { const result = await collectDesktopDiagnosticSections(makeSources()); const report = result.sections.join("\n\n"); @@ -73,6 +119,14 @@ describe("desktop diagnostic report", () => { expect(report.indexOf("Desktop app log tail")).toBeGreaterThan( report.indexOf("Desktop daemon log tail"), ); + expect(report).toContain("Desktop updater\n Platform: darwin"); + expect(report).toContain(" Current version: 0.7.0"); + expect(report).toContain(" Target version: 0.7.2"); + expect(report).toContain("ShipItState.plist"); + expect(report).toContain('{"launchAfterInstallation":true}'); + expect(report).toContain("ShipIt stdout log tail"); + expect(report).toContain(" File not found"); + expect(report).toContain("Installation completed successfully"); }); test("keeps daemon diagnostics when the Electron app log fails", async () => { diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.ts index 8fd26c6746..531ae11fbb 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.ts @@ -2,9 +2,12 @@ import { getDesktopAppLogs, getDesktopDaemonLogs, getDesktopDaemonStatus, + getDesktopUpdaterDiagnostics, type DesktopAppLogs, type DesktopDaemonLogs, type DesktopDaemonStatus, + type DesktopUpdaterDiagnosticFile, + type DesktopUpdaterDiagnostics, } from "@/desktop/daemon/desktop-daemon"; import { formatDiagnosticSection } from "./app-diagnostic-report"; @@ -19,12 +22,14 @@ export interface DesktopDiagnosticSources { getStatus: () => Promise; getDaemonLogs: () => Promise; getAppLogs: () => Promise; + getUpdaterDiagnostics: () => Promise; } const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = { getStatus: getDesktopDaemonStatus, getDaemonLogs: getDesktopDaemonLogs, getAppLogs: getDesktopAppLogs, + getUpdaterDiagnostics: getDesktopUpdaterDiagnostics, }; export async function collectDesktopDiagnosticSections( @@ -33,9 +38,10 @@ export async function collectDesktopDiagnosticSections( const sections: string[] = []; let failed = false; - const [daemonResult, appLogsResult] = await Promise.allSettled([ + const [daemonResult, appLogsResult, updaterResult] = await Promise.allSettled([ Promise.all([sources.getStatus(), sources.getDaemonLogs()]), sources.getAppLogs(), + sources.getUpdaterDiagnostics(), ]); if (daemonResult.status === "fulfilled") { @@ -62,12 +68,63 @@ export async function collectDesktopDiagnosticSections( ); } + if (updaterResult.status === "fulfilled") { + sections.push(...formatDesktopUpdaterSections(updaterResult.value)); + } else { + failed = true; + sections.push( + formatDiagnosticSection("Desktop updater", [ + { label: "Error", value: toMessage(updaterResult.reason) }, + ]), + ); + } + return { status: failed ? "failed" : "done", sections, }; } +function formatDesktopUpdaterSections(diagnostics: DesktopUpdaterDiagnostics): string[] { + const updaterDetails = [ + { label: "Platform", value: diagnostics.platform }, + { label: "Current version", value: diagnostics.currentVersion }, + { label: "Target version", value: diagnostics.targetVersion ?? "unknown" }, + { label: "ShipIt directory", value: diagnostics.shipItDirectory ?? "not applicable" }, + ]; + if (diagnostics.targetVersionError) { + updaterDetails.push({ label: "Target version error", value: diagnostics.targetVersionError }); + } + const sections = [formatDiagnosticSection("Desktop updater", updaterDetails)]; + + if (diagnostics.platform !== "darwin") return sections; + + sections.push( + formatUpdaterFileSection("ShipItState.plist", diagnostics.state), + formatUpdaterFileSection("ShipIt stdout log tail", diagnostics.stdout), + formatUpdaterFileSection("ShipIt stderr log tail", diagnostics.stderr), + ); + return sections; +} + +function formatUpdaterFileSection( + title: string, + file: DesktopUpdaterDiagnosticFile | null, +): string { + if (!file) { + return formatDiagnosticSection(title, [{ label: "Status", value: "unavailable" }]); + } + + const header = formatDiagnosticSection(title, [ + { label: "Path", value: file.path || "unknown" }, + { label: "Modified", value: file.modifiedAt ?? "unknown" }, + ]); + if (file.error) return `${header}\n Error: ${file.error}`; + if (!file.exists) return `${header}\n File not found`; + if (!file.contents) return `${header}\n No contents found`; + return `${header}\n${indentBlock(file.contents)}`; +} + function formatDesktopDaemonSections(input: { status: DesktopDaemonStatus; daemonLogs: DesktopDaemonLogs; diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 1985d9d7cf..f0c34060c9 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -262,6 +262,21 @@ async function waitForGuestSelector(client, browserId) { return false; } +async function waitForGuestActiveElement(client, browserId, elementId) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const evaluated = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.activeElement?.id ?? null", + }); + if (JSON.parse(evaluated.resultJson) === elementId) { + return true; + } + await delay(50); + } + return false; +} + async function createCallerAgent(daemonPort) { const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), @@ -492,12 +507,8 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, ); await clickGuestElement(page, client, browserId, "#typing-target"); - const activeGuestElement = await callBrowserTool(client, "browser_evaluate", { - browserId, - function: "() => document.activeElement?.id ?? null", - }); assert( - JSON.parse(activeGuestElement.resultJson) === "typing-target", + await waitForGuestActiveElement(client, browserId, "typing-target"), "Physical browser click did not focus the guest input", ); const focusedGuest = await page.evaluate( diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 0c5e2b798b..b07b75df4d 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -222,7 +222,10 @@ async function waitForFile(filePath, label) { const deadline = Date.now() + SMOKE_TIMEOUT_MS; while (Date.now() < deadline) { if (fs.existsSync(filePath)) { - return fs.readFileSync(filePath, "utf8"); + const contents = fs.readFileSync(filePath, "utf8"); + if (contents.trim().length > 0) { + return contents; + } } await delay(250); } diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 7e1cf10b5a..5fa62ef91a 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -37,6 +37,7 @@ publish: repo: paseo mac: artifactName: "Paseo-${version}-${arch}.${ext}" + minimumSystemVersion: "13.0.0" category: public.app-category.developer-tools icon: assets/icon.icns hardenedRuntime: true diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1a81cb001e..72f9ddf9c8 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -37,7 +37,7 @@ "devDependencies": { "@types/node": "24.6.0", "@types/ws": "^8.5.14", - "electron": "41.2.0", + "electron": "44.2.0", "electron-builder": "26.8.1", "typescript": "5.9.3", "unzip-crx-3": "^0.2.0", diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 70a6bc251c..8ccd2d1062 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -515,4 +515,13 @@ describe("daemon-manager commands", () => { ), }); }); + + it("exposes updater diagnostics through the desktop command boundary", () => { + const diagnostics = createDaemonCommandHandlers().desktop_update_diagnostics(); + + expect(diagnostics).toMatchObject({ + platform: process.platform, + currentVersion: "1.2.3", + }); + }); }); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index c565b226f3..4d3e5531eb 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -38,6 +38,7 @@ import type { DesktopSettings } from "../settings/desktop-settings.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; import { getDesktopAppLogs } from "../diagnostics/app-logs.js"; +import { getDesktopUpdaterDiagnostics } from "../diagnostics/updater.js"; import { deleteLegacySkillSelection, readLegacySkillSelection, @@ -531,6 +532,7 @@ export function createDaemonCommandHandlers(): Record restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), desktop_app_logs: () => getDesktopAppLogs(), + desktop_update_diagnostics: () => getDesktopUpdaterDiagnostics(), desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000, cli_daemon_status: () => getCliDaemonStatus(), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index cbea764dcb..c58c3f05e4 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -63,6 +63,22 @@ function createFakeMacBundle(options: { includeHelper: boolean }): { } describe("desktop packaging", () => { + it("uses an Electron runtime whose Squirrel handoff explicitly wakes ShipIt", () => { + const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + devDependencies?: Record; + }; + const electronVersion = pkg.devDependencies?.electron ?? "0.0.0"; + const electronMajor = Number(electronVersion.split(".")[0]); + + expect(electronMajor).toBeGreaterThanOrEqual(44); + }); + + it("requires macOS 13 or newer in the packaged application", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + + expect(config).toContain('minimumSystemVersion: "13.0.0"'); + }); + it("unpacks server zsh shell integration files for external shells", () => { const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); diff --git a/packages/desktop/src/diagnostics/updater.test.ts b/packages/desktop/src/diagnostics/updater.test.ts new file mode 100644 index 0000000000..bfe43aceab --- /dev/null +++ b/packages/desktop/src/diagnostics/updater.test.ts @@ -0,0 +1,141 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { collectDesktopUpdaterDiagnostics } from "./updater"; + +let testDirectory = ""; + +afterEach(() => { + if (testDirectory) { + rmSync(testDirectory, { recursive: true, force: true }); + testDirectory = ""; + } +}); + +describe("desktop updater diagnostics", () => { + it("collects the staged version and existing ShipIt evidence", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + const updateBundlePath = path.join(shipItDirectory, "update.test", "Paseo.app"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync( + path.join(shipItDirectory, "ShipItState.plist"), + JSON.stringify({ + launchAfterInstallation: true, + updateBundleURL: pathToFileURL(updateBundlePath).href, + }), + ); + writeFileSync(path.join(shipItDirectory, "ShipIt_stdout.log"), "stdout evidence\n"); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "stderr evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: (bundlePath) => (bundlePath === updateBundlePath ? "0.7.2" : null), + }); + + expect(diagnostics.currentVersion).toBe("0.7.0"); + expect(diagnostics.targetVersion).toBe("0.7.2"); + expect(diagnostics.targetVersionError).toBeNull(); + expect(diagnostics.shipItDirectory).toBe(shipItDirectory); + expect(diagnostics.state).toMatchObject({ exists: true }); + expect(diagnostics.stdout).toMatchObject({ + exists: true, + contents: "stdout evidence", + }); + expect(diagnostics.stderr).toMatchObject({ + exists: true, + contents: "stderr evidence", + }); + expect(diagnostics.state?.modifiedAt).not.toBeNull(); + }); + + it("reports malformed ShipIt state without hiding other evidence", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync(path.join(shipItDirectory, "ShipItState.plist"), "not JSON"); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "installer evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => "unused", + }); + + expect(diagnostics.targetVersion).toBeNull(); + expect(diagnostics.targetVersionError).toMatch(/JSON|Unexpected token/); + expect(diagnostics.stderr?.contents).toBe("installer evidence"); + }); + + it("reports bundle version lookup failures", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync( + path.join(shipItDirectory, "ShipItState.plist"), + JSON.stringify({ updateBundleURL: pathToFileURL(path.join(shipItDirectory, "Paseo.app")) }), + ); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => { + throw new Error("plutil failed"); + }, + }); + + expect(diagnostics.targetVersion).toBeNull(); + expect(diagnostics.targetVersionError).toBe("plutil failed"); + }); + + it("keeps readable ShipIt evidence when another file cannot be read", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(path.join(shipItDirectory, "ShipIt_stdout.log"), { recursive: true }); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "installer evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => null, + }); + + expect(diagnostics.stdout).toMatchObject({ + exists: true, + contents: "", + error: expect.any(String), + }); + expect(diagnostics.stderr).toMatchObject({ + exists: true, + contents: "installer evidence", + error: null, + }); + }); + + it("does not look for ShipIt files outside macOS", () => { + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "linux", + currentVersion: "0.7.2", + cachePath: "/unused", + readBundleVersion: () => null, + }); + + expect(diagnostics).toEqual({ + platform: "linux", + currentVersion: "0.7.2", + targetVersion: null, + targetVersionError: null, + shipItDirectory: null, + state: null, + stdout: null, + stderr: null, + }); + }); +}); diff --git a/packages/desktop/src/diagnostics/updater.ts b/packages/desktop/src/diagnostics/updater.ts new file mode 100644 index 0000000000..465c5d0390 --- /dev/null +++ b/packages/desktop/src/diagnostics/updater.ts @@ -0,0 +1,148 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { app } from "electron"; +import { tailFile } from "./tail-file.js"; + +const SHIPIT_DIRECTORY_NAME = "sh.paseo.desktop.ShipIt"; +const SHIPIT_LOG_TAIL_LINES = 100; + +export interface DesktopUpdaterDiagnosticFile { + path: string; + exists: boolean; + modifiedAt: string | null; + contents: string; + error: string | null; +} + +export interface DesktopUpdaterDiagnostics { + platform: NodeJS.Platform; + currentVersion: string; + targetVersion: string | null; + targetVersionError: string | null; + shipItDirectory: string | null; + state: DesktopUpdaterDiagnosticFile | null; + stdout: DesktopUpdaterDiagnosticFile | null; + stderr: DesktopUpdaterDiagnosticFile | null; +} + +interface DesktopUpdaterDiagnosticEnvironment { + platform: NodeJS.Platform; + currentVersion: string; + cachePath: string; + readBundleVersion(bundlePath: string): string | null; +} + +export function collectDesktopUpdaterDiagnostics( + environment: DesktopUpdaterDiagnosticEnvironment, +): DesktopUpdaterDiagnostics { + const { platform, currentVersion, cachePath, readBundleVersion } = environment; + if (platform !== "darwin") { + return { + platform, + currentVersion, + targetVersion: null, + targetVersionError: null, + shipItDirectory: null, + state: null, + stdout: null, + stderr: null, + }; + } + + const shipItDirectory = path.join(cachePath, SHIPIT_DIRECTORY_NAME); + const state = readDiagnosticFile(path.join(shipItDirectory, "ShipItState.plist")); + const targetVersion = diagnoseTargetVersion(state.contents, readBundleVersion); + return { + platform, + currentVersion, + ...targetVersion, + shipItDirectory, + state, + stdout: readDiagnosticFile(path.join(shipItDirectory, "ShipIt_stdout.log"), true), + stderr: readDiagnosticFile(path.join(shipItDirectory, "ShipIt_stderr.log"), true), + }; +} + +export function getDesktopUpdaterDiagnostics(): DesktopUpdaterDiagnostics { + return collectDesktopUpdaterDiagnostics({ + platform: process.platform, + currentVersion: app.getVersion(), + cachePath: path.join(app.getPath("home"), "Library", "Caches"), + readBundleVersion: readMacBundleVersion, + }); +} + +function readDiagnosticFile(filePath: string, tail = false): DesktopUpdaterDiagnosticFile { + let exists = false; + let modifiedAt: string | null = null; + try { + modifiedAt = statSync(filePath).mtime.toISOString(); + exists = true; + const contents = tail + ? tailFile(filePath, SHIPIT_LOG_TAIL_LINES, { throwOnReadError: true }) + : readFileSync(filePath, "utf8"); + return { path: filePath, exists, modifiedAt, contents, error: null }; + } catch (error) { + if (isMissingFileError(error)) { + return { path: filePath, exists: false, modifiedAt: null, contents: "", error: null }; + } + return { + path: filePath, + exists, + modifiedAt, + contents: "", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function diagnoseTargetVersion( + stateContents: string, + readBundleVersion: (bundlePath: string) => string | null, +): Pick { + try { + return { + targetVersion: readTargetVersion(stateContents, readBundleVersion), + targetVersionError: null, + }; + } catch (error) { + return { + targetVersion: null, + targetVersionError: error instanceof Error ? error.message : String(error), + }; + } +} + +function readTargetVersion( + stateContents: string, + readBundleVersion: (bundlePath: string) => string | null, +): string | null { + if (!stateContents) return null; + + const state = JSON.parse(stateContents) as unknown; + if (!isRecord(state) || typeof state.updateBundleURL !== "string") return null; + return readBundleVersion(fileURLToPath(state.updateBundleURL)); +} + +function readMacBundleVersion(bundlePath: string): string | null { + return execFileSync( + "/usr/bin/plutil", + [ + "-extract", + "CFBundleShortVersionString", + "raw", + path.join(bundlePath, "Contents", "Info.plist"), + ], + { encoding: "utf8" }, + ).trim(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isMissingFileError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index e3be85bca0..5f55ff6099 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createAppUpdateService, + type AppUpdateInstallRequest, type AppUpdateRuntime, type AppUpdateRuntimeConfiguration, type RuntimeUpdateInfo, @@ -27,9 +28,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } | null = null; checkCount = 0; downloadCallCount = 0; + requestedDownloadVersions: string[] = []; downloadedVersions: string[] = []; installedVersions: string[] = []; - installModes: Array<{ isSilent: boolean; isForceRunAfter: boolean }> = []; + installModes: Array<{ targetVersion: string; isSilent: boolean; isForceRunAfter: boolean }> = []; configure(input: AppUpdateRuntimeConfiguration): void { this.configuration = input; @@ -130,8 +132,9 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { return { ...result, isUpdateAvailable }; } - async downloadUpdate(): Promise { + async downloadUpdate(targetVersion: string): Promise { this.downloadCallCount += 1; + this.requestedDownloadVersions.push(targetVersion); if (this.activeDownload) { return this.activeDownload.promise; } @@ -140,10 +143,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } } - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + quitAndInstall({ targetVersion, isSilent, isForceRunAfter }: AppUpdateInstallRequest): void { if (this.downloadedUpdate) { this.installedVersions.push(this.downloadedUpdate.version); - this.installModes.push({ isSilent, isForceRunAfter }); + this.installModes.push({ targetVersion, isSilent, isForceRunAfter }); } } } @@ -387,7 +390,9 @@ describe("app update service", () => { expect(installed).toBe(true); expect(runtime.installedVersions).toEqual(["1.2.5"]); - expect(runtime.installModes).toEqual([{ isSilent: true, isForceRunAfter: false }]); + expect(runtime.installModes).toEqual([ + { targetVersion: "1.2.5", isSilent: true, isForceRunAfter: false }, + ]); }); it("does not install an older download while its replacement is still rolling out", async () => { @@ -491,7 +496,9 @@ describe("app update service", () => { expect(result.installed).toBe(true); expect(runtime.installedVersions).toEqual(["1.2.5"]); - expect(runtime.installModes).toEqual([{ isSilent: false, isForceRunAfter: true }]); + expect(runtime.installModes).toEqual([ + { targetVersion: "1.2.5", isSilent: false, isForceRunAfter: true }, + ]); }); it("waits for a stale active download before downloading and installing the rechecked version", async () => { @@ -518,6 +525,7 @@ describe("app update service", () => { expect(result.installed).toBe(true); expect(runtime.downloadedVersions).toEqual(["1.2.4", "1.2.5"]); + expect(runtime.requestedDownloadVersions).toEqual(["1.2.5"]); expect(runtime.installedVersions).toEqual(["1.2.5"]); }); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts index 876a5a5553..21789950bf 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -41,11 +41,17 @@ export interface AppUpdateRuntimeConfiguration { onError(error: unknown): void; } +export interface AppUpdateInstallRequest { + targetVersion: string; + isSilent: boolean; + isForceRunAfter: boolean; +} + export interface AppUpdateRuntime { configure(input: AppUpdateRuntimeConfiguration): void; checkForUpdates(): Promise; - downloadUpdate(): Promise; - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void; + downloadUpdate(targetVersion: string): Promise; + quitAndInstall(input: AppUpdateInstallRequest): void; } export interface AppUpdateService { @@ -101,15 +107,21 @@ function buildCheckResult(input: { async function performQuitAndInstall( runtime: AppUpdateRuntime, { + targetVersion, onBeforeQuit, restart, }: { + targetVersion: string; onBeforeQuit?: () => Promise; restart: boolean; }, ): Promise { if (onBeforeQuit) await onBeforeQuit(); - runtime.quitAndInstall(/* isSilent */ !restart, /* isForceRunAfter */ restart); + runtime.quitAndInstall({ + targetVersion, + isSilent: !restart, + isForceRunAfter: restart, + }); } function getErrorMessage(error: unknown): string { @@ -356,7 +368,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const attemptedVersion: string = preparingUpdateVersion ?? readyVersion; preparingUpdateVersion ??= readyVersion; try { - await deps.runtime.downloadUpdate(); + await deps.runtime.downloadUpdate(attemptedVersion); } catch (error) { if ( attemptedVersion !== readyVersion && @@ -406,7 +418,11 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } if (isReadyToInstallVersion(readyVersion)) { - await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); + await performQuitAndInstall(deps.runtime, { + targetVersion: readyVersion, + onBeforeQuit, + restart, + }); return { installed: true, version: readyVersion, @@ -426,7 +442,11 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer message: "A newer update was found and will be installed later.", }; } - await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); + await performQuitAndInstall(deps.runtime, { + targetVersion: readyVersion, + onBeforeQuit, + restart, + }); return { installed: true, diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index d4c42c0322..6cd6da2c7e 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -39,6 +39,7 @@ vi.mock("electron-updater", () => ({ import { bucketFromStagingUserId, checkForAppUpdate, + createAppUpdateLifecycleLogger, resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, @@ -95,6 +96,50 @@ describe("checkForAppUpdate", () => { expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); + + it("logs the update handoff with current and selected target versions", () => { + const info = vi.fn(); + const lifecycleLog = createAppUpdateLifecycleLogger({ info }); + + lifecycleLog.checkStarted({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + lifecycleLog.checkCompleted({ + currentVersion: "1.2.3", + targetVersion: "1.2.5", + releaseChannel: "stable", + intent: "manual", + hasUpdate: true, + readyToInstall: true, + errorMessage: null, + }); + lifecycleLog.updateDownloaded("1.2.4"); + lifecycleLog.downloadRequested("1.2.5"); + lifecycleLog.quitAndInstallRequested({ + targetVersion: "1.2.5", + isSilent: false, + isForceRunAfter: true, + }); + + expect(info).toHaveBeenCalledWith("[auto-updater] check started", { + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] update downloaded", { + targetVersion: "1.2.4", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] download requested", { + targetVersion: "1.2.5", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] quitAndInstall requested", { + targetVersion: "1.2.5", + isSilent: false, + isForceRunAfter: true, + }); + }); }); describe("shouldInstallAppUpdateOnQuit", () => { diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 46656a3917..ef71dc25b4 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -3,10 +3,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { app } from "electron"; import { UUID } from "builder-util-runtime"; +import log from "electron-log/main"; import { autoUpdater } from "electron-updater"; import { createAppUpdateService, type AppUpdateCheckResult, + type AppUpdateInstallRequest, type AppUpdateInstallResult, type AppUpdateRuntime, type AppUpdateRuntimeConfiguration, @@ -35,6 +37,48 @@ let cachedStagingUserIdPromise: Promise | null = null; const UPDATE_CHANNEL_NOT_PUBLISHED_CODE = "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND"; +interface AppUpdateLogSink { + info(message: string, details: object): void; +} + +interface AppUpdateCheckLogDetails { + currentVersion: string; + releaseChannel: AppReleaseChannel; + intent: AppUpdateCheckIntent; +} + +interface AppUpdateCheckCompletedLogDetails extends AppUpdateCheckLogDetails { + targetVersion: string; + hasUpdate: boolean; + readyToInstall: boolean; + errorMessage: string | null; +} + +export function createAppUpdateLifecycleLogger(logger: AppUpdateLogSink) { + return { + checkStarted(details: AppUpdateCheckLogDetails): void { + logger.info("[auto-updater] check started", details); + }, + checkCompleted(details: AppUpdateCheckCompletedLogDetails): void { + logger.info("[auto-updater] check completed", details); + }, + updateAvailable(targetVersion: string): void { + logger.info("[auto-updater] update available", { targetVersion }); + }, + updateDownloaded(targetVersion: string): void { + logger.info("[auto-updater] update downloaded", { targetVersion }); + }, + downloadRequested(targetVersion: string): void { + logger.info("[auto-updater] download requested", { targetVersion }); + }, + quitAndInstallRequested(details: AppUpdateInstallRequest): void { + logger.info("[auto-updater] quitAndInstall requested", details); + }, + }; +} + +const updateLifecycleLog = createAppUpdateLifecycleLogger(log); + function isUpdateChannelNotPublished(error: unknown): boolean { return ( typeof error === "object" && @@ -133,10 +177,14 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { }; autoUpdater.on("update-available", (info) => { - input.onUpdateAvailable(info as RuntimeUpdateInfo); + const updateInfo = info as RuntimeUpdateInfo; + updateLifecycleLog.updateAvailable(updateInfo.version); + input.onUpdateAvailable(updateInfo); }); autoUpdater.on("update-downloaded", (info) => { - input.onUpdateDownloaded(info as RuntimeUpdateInfo); + const updateInfo = info as RuntimeUpdateInfo; + updateLifecycleLog.updateDownloaded(updateInfo.version); + input.onUpdateDownloaded(updateInfo); }); autoUpdater.on("error", (error) => { if (isUpdateChannelNotPublished(error)) return; @@ -158,12 +206,18 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { } } - downloadUpdate(): Promise { + downloadUpdate(targetVersion: string): Promise { + updateLifecycleLog.downloadRequested(targetVersion); return autoUpdater.downloadUpdate(); } - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + quitAndInstall({ targetVersion, isSilent, isForceRunAfter }: AppUpdateInstallRequest): void { autoUpdater.autoRunAppAfterInstall = isForceRunAfter; + updateLifecycleLog.quitAndInstallRequested({ + targetVersion, + isSilent, + isForceRunAfter, + }); autoUpdater.quitAndInstall(isSilent, isForceRunAfter); } } @@ -197,7 +251,22 @@ export async function checkForAppUpdate({ releaseChannel: AppReleaseChannel; intent: AppUpdateCheckIntent; }): Promise { - return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent }); + updateLifecycleLog.checkStarted({ currentVersion, releaseChannel, intent }); + const result = await appUpdateService.checkForAppUpdate({ + currentVersion, + releaseChannel, + intent, + }); + updateLifecycleLog.checkCompleted({ + currentVersion, + targetVersion: result.latestVersion, + releaseChannel, + intent, + hasUpdate: result.hasUpdate, + readyToInstall: result.readyToInstall, + errorMessage: result.errorMessage, + }); + return result; } export async function downloadAndInstallUpdate( diff --git a/packages/desktop/src/features/browser-capture.test.ts b/packages/desktop/src/features/browser-capture.test.ts index d14c7734ee..5c7d16a77a 100644 --- a/packages/desktop/src/features/browser-capture.test.ts +++ b/packages/desktop/src/features/browser-capture.test.ts @@ -10,7 +10,7 @@ function image(dataUrl = "data:image/png;base64,capture"): BrowserCaptureImage { } function harness(guest: BrowserCaptureGuest | null = null) { - const clipboard = { write: vi.fn(), writeImage: vi.fn(), writeText: vi.fn() }; + const clipboard = { write: vi.fn(async () => undefined) }; const decodeImage = vi.fn(() => image("data:image/png;base64,clipboard")); const warn = vi.fn(); return { @@ -51,16 +51,21 @@ describe("browser capture service", () => { expect(capturePage).not.toHaveBeenCalled(); }); - it("writes text and a decoded image to the clipboard atomically", () => { + it("writes text and a decoded image to the clipboard atomically", async () => { const { service, clipboard } = harness(); - expect(service.copy({ text: "button", imageDataUrl: "data:image/png;base64,value" })).toBe( - true, - ); + await expect( + service.copy({ text: "button", imageDataUrl: "data:image/png;base64,value" }), + ).resolves.toBe(true); expect(clipboard.write).toHaveBeenCalledWith({ text: "button", image: expect.any(Object), }); - expect(clipboard.writeText).not.toHaveBeenCalled(); - expect(clipboard.writeImage).not.toHaveBeenCalled(); + }); + + it("keeps text-only clipboard writes", async () => { + const { service, clipboard } = harness(); + + await expect(service.copy({ text: "button" })).resolves.toBe(true); + expect(clipboard.write).toHaveBeenCalledWith({ text: "button", image: null }); }); }); diff --git a/packages/desktop/src/features/browser-capture.ts b/packages/desktop/src/features/browser-capture.ts index 792567d424..c2c7326ce7 100644 --- a/packages/desktop/src/features/browser-capture.ts +++ b/packages/desktop/src/features/browser-capture.ts @@ -15,10 +15,13 @@ export interface BrowserCaptureRect { height: number; } +interface BrowserCaptureClipboardPayload { + text: string | null; + image: TImage | null; +} + interface BrowserCaptureClipboard { - write(input: { text: string; image: TImage }): void; - writeImage(image: TImage): void; - writeText(text: string): void; + write(input: BrowserCaptureClipboardPayload): Promise; } interface BrowserCaptureDependencies { @@ -34,7 +37,7 @@ export interface BrowserCaptureService { hostWebContentsId: number; rect: unknown; }): Promise; - copy(payload: unknown): boolean; + copy(payload: unknown): Promise; } function captureRect(value: unknown): BrowserCaptureRect | null { @@ -88,7 +91,7 @@ export function createBrowserCaptureService( } }, - copy(payload) { + async copy(payload) { const { text, imageDataUrl } = copyPayload(payload); let image: TImage | null = null; if (imageDataUrl) { @@ -99,10 +102,8 @@ export function createBrowserCaptureService( dependencies.warn("image-decode-failed", { error }); } } - if (text && image) dependencies.clipboard.write({ text, image }); - else if (image) dependencies.clipboard.writeImage(image); - else if (text) dependencies.clipboard.writeText(text); - else return false; + if (!text && !image) return false; + await dependencies.clipboard.write({ text, image }); return true; }, }; diff --git a/packages/desktop/src/features/browser-profile.test.ts b/packages/desktop/src/features/browser-profile.test.ts index 029a5ae940..09d7c40b43 100644 --- a/packages/desktop/src/features/browser-profile.test.ts +++ b/packages/desktop/src/features/browser-profile.test.ts @@ -159,7 +159,7 @@ describe("clearPaseoBrowserProfile", () => { "localstorage", "serviceworkers", "cachestorage", - "websql", + "shadercache", ], }, ]); diff --git a/packages/desktop/src/features/browser-profile.ts b/packages/desktop/src/features/browser-profile.ts index 307567e512..f8f1d19328 100644 --- a/packages/desktop/src/features/browser-profile.ts +++ b/packages/desktop/src/features/browser-profile.ts @@ -10,7 +10,7 @@ const PASEO_BROWSER_STORAGE_TYPES = [ "localstorage", "serviceworkers", "cachestorage", - "websql", + "shadercache", ] as const; interface BrowserProfileSession { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index b0610565ca..05230b2556 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -14,6 +14,7 @@ import { app, autoUpdater as electronAutoUpdater, BrowserWindow, + ClipboardItem, clipboard, Menu, ipcMain, @@ -130,6 +131,12 @@ const bootstrapComplete = new Promise((resolve) => { let bootstrapIsComplete = false; app.setName(APP_NAME); +log.info("[desktop] app startup", { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + isPackaged: app.isPackaged, +}); interface AttachedBrowserInput { browserId: string; @@ -519,7 +526,19 @@ ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds const browserCapture = createBrowserCaptureService({ findGuest: getPaseoBrowserWebContentsForHostWindow, decodeImage: (dataUrl) => nativeImage.createFromDataURL(dataUrl), - clipboard, + clipboard: { + write: async ({ text, image }) => { + const items: Record = {}; + if (text) items["text/plain"] = text; + if (image) { + const png = image.toPNG(); + const bytes = new Uint8Array(png.byteLength); + bytes.set(png); + items["image/png"] = new Blob([bytes], { type: "image/png" }); + } + await clipboard.write([new ClipboardItem(items)]); + }, + }, warn: (event, details) => log.warn(`[browser-capture] ${event}`, details), }); @@ -1031,7 +1050,10 @@ const quitLifecycle = createQuitLifecycle({ }); // electron-updater forwards this event through Electron's built-in autoUpdater. -electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); +electronAutoUpdater.on("before-quit-for-update", () => { + log.info("[auto-updater] before-quit-for-update", { currentVersion: app.getVersion() }); + quitLifecycle.handleBeforeQuitForUpdate(); +}); app.on("before-quit", quitLifecycle.handleBeforeQuit); registerExternalQuitSignals({ signals: process, quit: () => app.quit() }); diff --git a/packages/website/src/routes/download.tsx b/packages/website/src/routes/download.tsx index 5efed4de4b..f2623e37a4 100644 --- a/packages/website/src/routes/download.tsx +++ b/packages/website/src/routes/download.tsx @@ -86,10 +86,13 @@ function Download() {
- - - - +
+ + + + + Requires macOS 13 or newer +
{!onBeta && ( diff --git a/scripts/merge-mac-manifest.mjs b/scripts/merge-mac-manifest.mjs index 7ebb92957d..78a5553b56 100644 --- a/scripts/merge-mac-manifest.mjs +++ b/scripts/merge-mac-manifest.mjs @@ -1,13 +1,20 @@ import fs from "node:fs"; import { dump, load } from "js-yaml"; +// electron-updater compares this with os.release(), which reports the Darwin +// kernel version. Darwin 22 is macOS 13 Ventura. +export const MACOS_MINIMUM_DARWIN_VERSION = "22.0.0"; + export function mergeMacManifest(arm64Path, x64Path, outputPath) { const arm64 = load(fs.readFileSync(arm64Path, "utf8")); const x64 = load(fs.readFileSync(x64Path, "utf8")); const files = [...(arm64.files ?? []), ...(x64.files ?? [])].filter( (file, index, all) => all.findIndex((entry) => entry.url === file.url) === index, ); - const output = dump({ ...arm64, files }, { lineWidth: -1, noRefs: true }); + const output = dump( + { ...arm64, files, minimumSystemVersion: MACOS_MINIMUM_DARWIN_VERSION }, + { lineWidth: -1, noRefs: true }, + ); fs.writeFileSync(outputPath, output); return output; } diff --git a/scripts/merge-mac-manifest.test.mjs b/scripts/merge-mac-manifest.test.mjs index f007b77afd..cd8ceaf957 100644 --- a/scripts/merge-mac-manifest.test.mjs +++ b/scripts/merge-mac-manifest.test.mjs @@ -22,6 +22,7 @@ test("preserves unknown fields while merging files by url", () => { mergeMacManifest(arm64Path, x64Path, outputPath); const output = readFileSync(outputPath, "utf8"); assert.match(output, /stagingPercentage: 25/); + assert.match(output, /minimumSystemVersion: 22\.0\.0/); assert.match(output, /url: app-arm64\.zip/); assert.match(output, /url: app-x64\.zip/); } finally { diff --git a/scripts/validate-desktop-manifests.mjs b/scripts/validate-desktop-manifests.mjs new file mode 100644 index 0000000000..ace4a4060b --- /dev/null +++ b/scripts/validate-desktop-manifests.mjs @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { load } from "js-yaml"; +import { MACOS_MINIMUM_DARWIN_VERSION } from "./merge-mac-manifest.mjs"; + +export function validateDesktopManifests({ releaseDate, rolloutHours }, paths) { + if (!Number.isFinite(rolloutHours) || rolloutHours < 0) { + throw new Error(`expected non-negative rolloutHours, got ${rolloutHours}`); + } + + for (const manifestPath of paths) { + const manifest = load(fs.readFileSync(manifestPath, "utf8")) ?? {}; + if (manifest.rolloutHours !== rolloutHours) { + throw new Error( + `${manifestPath}: rolloutHours=${manifest.rolloutHours}, expected ${rolloutHours}`, + ); + } + if (manifest.releaseDate !== releaseDate) { + throw new Error( + `${manifestPath}: releaseDate=${manifest.releaseDate}, expected ${releaseDate}`, + ); + } + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error(`${manifestPath}: missing or invalid version`); + } + if ( + manifestPath.endsWith("-mac.yml") && + manifest.minimumSystemVersion !== MACOS_MINIMUM_DARWIN_VERSION + ) { + throw new Error( + `${manifestPath}: minimumSystemVersion=${manifest.minimumSystemVersion}, expected ${MACOS_MINIMUM_DARWIN_VERSION}`, + ); + } + } +} + +function parseArgs(argv) { + const paths = []; + let releaseDate; + let rolloutHours; + + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === "--release-date") { + releaseDate = argv[++index]; + } else if (argv[index] === "--rollout-hours") { + rolloutHours = Number(argv[++index]); + } else { + paths.push(argv[index]); + } + } + + if (!releaseDate || rolloutHours === undefined || paths.length === 0) { + throw new Error( + "Usage: node scripts/validate-desktop-manifests.mjs --release-date --rollout-hours ", + ); + } + + return { releaseDate, rolloutHours, paths }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + const { releaseDate, rolloutHours, paths } = parseArgs(process.argv.slice(2)); + validateDesktopManifests({ releaseDate, rolloutHours }, paths); +} diff --git a/scripts/validate-desktop-manifests.test.mjs b/scripts/validate-desktop-manifests.test.mjs new file mode 100644 index 0000000000..2c461ac35d --- /dev/null +++ b/scripts/validate-desktop-manifests.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; +import { validateDesktopManifests } from "./validate-desktop-manifests.mjs"; + +const releaseDate = "2026-09-04T00:00:00.000Z"; +const scriptPath = fileURLToPath(new URL("./validate-desktop-manifests.mjs", import.meta.url)); + +function withManifest(contents, run) { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-validate-desktop-manifest-")); + const manifestPath = path.join(dir, "latest-mac.yml"); + try { + writeFileSync(manifestPath, contents); + run(manifestPath); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +} + +test("accepts a guarded macOS update manifest", () => { + withManifest( + `version: 0.7.3\nreleaseDate: '${releaseDate}'\nrolloutHours: 36\nminimumSystemVersion: 22.0.0\n`, + (manifestPath) => { + validateDesktopManifests({ releaseDate, rolloutHours: 36 }, [manifestPath]); + }, + ); +}); + +test("validates release manifests through the workflow CLI", () => { + withManifest( + `version: 0.7.3\nreleaseDate: '${releaseDate}'\nrolloutHours: 36\nminimumSystemVersion: 22.0.0\n`, + (manifestPath) => { + const result = spawnSync( + process.execPath, + [scriptPath, "--release-date", releaseDate, "--rollout-hours", "36", manifestPath], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 0, result.stderr); + }, + ); +}); + +test("rejects a macOS update manifest without the system floor", () => { + withManifest( + `version: 0.7.3\nreleaseDate: '${releaseDate}'\nrolloutHours: 36\n`, + (manifestPath) => { + assert.throws( + () => validateDesktopManifests({ releaseDate, rolloutHours: 36 }, [manifestPath]), + /minimumSystemVersion=undefined, expected 22\.0\.0/, + ); + }, + ); +}); + +test("rejects invalid rollout metadata", () => { + withManifest( + `version: 0.7.3\nreleaseDate: '${releaseDate}'\nrolloutHours: 24\nminimumSystemVersion: 22.0.0\n`, + (manifestPath) => { + assert.throws( + () => validateDesktopManifests({ releaseDate, rolloutHours: 36 }, [manifestPath]), + /rolloutHours=24, expected 36/, + ); + }, + ); +});