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
This commit is contained in:
Mohamed Boudra
2026-09-04 22:17:39 +02:00
committed by GitHub
parent 35d2457d83
commit 9e90b86afa
32 changed files with 922 additions and 249 deletions
-23
View File
@@ -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"
+4 -22
View File
@@ -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=()
+10
View File
@@ -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.<N+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
+1 -1
View File
@@ -1 +1 @@
sha256-1kiHsXpooSqC38MwZP2E7rQK+Zhuv0fUOxUHfnM30wA=
sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE=
+44 -152
View File
@@ -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",
+1
View File
@@ -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",
@@ -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<DesktopAppLogs> {
};
}
export async function getDesktopUpdaterDiagnostics(): Promise<DesktopUpdaterDiagnostics> {
return parseDesktopUpdaterDiagnostics(await invokeDesktopCommand("desktop_update_diagnostics"));
}
export async function getCliDaemonStatus(): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
if (typeof raw !== "string") {
@@ -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 () => {
@@ -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<DesktopDaemonStatus>;
getDaemonLogs: () => Promise<DesktopDaemonLogs>;
getAppLogs: () => Promise<DesktopAppLogs>;
getUpdaterDiagnostics: () => Promise<DesktopUpdaterDiagnostics>;
}
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;
+16 -5
View File
@@ -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(
+4 -1
View File
@@ -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);
}
+1
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -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",
});
});
});
@@ -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<string, DesktopCommandHand
restart_desktop_daemon: () => 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 ?? {}),
@@ -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<string, string>;
};
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");
@@ -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,
});
});
});
+148
View File
@@ -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<DesktopUpdaterDiagnostics, "targetVersion" | "targetVersionError"> {
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<string, unknown> {
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";
}
@@ -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<void> {
async downloadUpdate(targetVersion: string): Promise<void> {
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"]);
});
@@ -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<RuntimeUpdateCheckResult | null>;
downloadUpdate(): Promise<unknown>;
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void;
downloadUpdate(targetVersion: string): Promise<unknown>;
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<void>;
restart: boolean;
},
): Promise<void> {
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,
@@ -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", () => {
+74 -5
View File
@@ -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<string> | 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<unknown> {
downloadUpdate(targetVersion: string): Promise<unknown> {
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<AppUpdateCheckResult> {
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(
@@ -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 });
});
});
@@ -15,10 +15,13 @@ export interface BrowserCaptureRect {
height: number;
}
interface BrowserCaptureClipboardPayload<TImage extends BrowserCaptureImage> {
text: string | null;
image: TImage | null;
}
interface BrowserCaptureClipboard<TImage extends BrowserCaptureImage> {
write(input: { text: string; image: TImage }): void;
writeImage(image: TImage): void;
writeText(text: string): void;
write(input: BrowserCaptureClipboardPayload<TImage>): Promise<void>;
}
interface BrowserCaptureDependencies<TImage extends BrowserCaptureImage> {
@@ -34,7 +37,7 @@ export interface BrowserCaptureService {
hostWebContentsId: number;
rect: unknown;
}): Promise<string | null>;
copy(payload: unknown): boolean;
copy(payload: unknown): Promise<boolean>;
}
function captureRect(value: unknown): BrowserCaptureRect | null {
@@ -88,7 +91,7 @@ export function createBrowserCaptureService<TImage extends BrowserCaptureImage>(
}
},
copy(payload) {
async copy(payload) {
const { text, imageDataUrl } = copyPayload(payload);
let image: TImage | null = null;
if (imageDataUrl) {
@@ -99,10 +102,8 @@ export function createBrowserCaptureService<TImage extends BrowserCaptureImage>(
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;
},
};
@@ -159,7 +159,7 @@ describe("clearPaseoBrowserProfile", () => {
"localstorage",
"serviceworkers",
"cachestorage",
"websql",
"shadercache",
],
},
]);
@@ -10,7 +10,7 @@ const PASEO_BROWSER_STORAGE_TYPES = [
"localstorage",
"serviceworkers",
"cachestorage",
"websql",
"shadercache",
] as const;
interface BrowserProfileSession {
+24 -2
View File
@@ -14,6 +14,7 @@ import {
app,
autoUpdater as electronAutoUpdater,
BrowserWindow,
ClipboardItem,
clipboard,
Menu,
ipcMain,
@@ -130,6 +131,12 @@ const bootstrapComplete = new Promise<void>((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<Electron.NativeImage>({
findGuest: getPaseoBrowserWebContentsForHostWindow,
decodeImage: (dataUrl) => nativeImage.createFromDataURL(dataUrl),
clipboard,
clipboard: {
write: async ({ text, image }) => {
const items: Record<string, string | Blob> = {};
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() });
+7 -4
View File
@@ -86,10 +86,13 @@ function Download() {
<div className="divide-y divide-border">
<PlatformRow icon={AppleIcon} label="macOS">
<PillGroup>
<DownloadPill href={urls.macAppleSilicon} label="Apple Silicon" />
<DownloadPill href={urls.macIntel} label="Intel" />
</PillGroup>
<div className="flex flex-col items-start gap-2 sm:items-end">
<PillGroup>
<DownloadPill href={urls.macAppleSilicon} label="Apple Silicon" />
<DownloadPill href={urls.macIntel} label="Intel" />
</PillGroup>
<span className="text-xs text-muted-foreground">Requires macOS 13 or newer</span>
</div>
</PlatformRow>
{!onBeta && (
+8 -1
View File
@@ -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;
}
+1
View File
@@ -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 {
+65
View File
@@ -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 <date> --rollout-hours <hours> <manifest...>",
);
}
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);
}
@@ -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/,
);
},
);
});