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/, + ); + }, + ); +});