From da236308fdde9540fe3809f05abd82ccbd6a9db4 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sun, 6 Sep 2026 21:27:58 -0400 Subject: [PATCH] feat(desktop): wire macOS bundle updates and guarded feed publication Route packaged macOS bundles and Light through the updater strategy. Use electron-updater 6.8.9 and wait for native signature acceptance before backend teardown. Keep checkout and Store ownership separate. Share Darwin feed paths between packaging, runtime and publication. Validate both native feeds, verify streamed artifact hashes, prevent same-tag artifact replacement, and conditionally update the channel pointer. Protect live feed references during canary retention. Use one notarization owner. Require publishing credentials and validate the stapled app. Keep Windows, Linux and Termux jobs unchanged. Verified with updater/feed unit and transport tests, release-helper tests, desktop typechecks, the desktop JS build, and workflow lint. No E2E, native macOS install, release dispatch or public publication was run. --- .github/workflows/canary-release.yml | 2 + .github/workflows/desktop-bundled-release.yml | 58 +-- apps/desktop/electron-builder.config.cjs | 10 + apps/desktop/electron/app-updater.ts | 9 +- apps/desktop/electron/main.ts | 93 +++-- apps/desktop/electron/updater/index.ts | 28 +- .../electron/updater/mac-client.test.ts | 41 ++ apps/desktop/electron/updater/mac-client.ts | 37 ++ apps/desktop/electron/updater/mac.test.ts | 115 ++++++ apps/desktop/electron/updater/mac.ts | 104 +++++ apps/desktop/electron/updater/updater.test.ts | 36 +- apps/desktop/package.json | 1 + apps/desktop/scripts/write-build-stamp.mjs | 4 +- .../scripts/write-build-stamp.test.mjs | 9 +- apps/desktop/src/global.d.ts | 1 + apps/desktop/src/lib/update-copy.test.ts | 5 + apps/desktop/src/lib/update-copy.ts | 4 +- apps/desktop/tsconfig.electron.json | 2 +- apps/desktop/update-feed.cjs | 36 ++ apps/desktop/update-feed.d.cts | 20 + docs/macos-bundle-updates.md | 61 +++ package-lock.json | 365 +++++++----------- package.json | 10 +- scripts/darwin-feed.mjs | 90 +++++ scripts/msix-shared.mjs | 2 + scripts/r2-release.mjs | 215 +++-------- tests-js/darwin-feed.test.mjs | 148 +++++++ tests-js/macos-publish-config.test.mjs | 24 ++ tests-js/r2-release.test.mjs | 59 --- 29 files changed, 1034 insertions(+), 555 deletions(-) create mode 100644 apps/desktop/electron/updater/mac-client.test.ts create mode 100644 apps/desktop/electron/updater/mac-client.ts create mode 100644 apps/desktop/electron/updater/mac.test.ts create mode 100644 apps/desktop/electron/updater/mac.ts create mode 100644 apps/desktop/update-feed.cjs create mode 100644 apps/desktop/update-feed.d.cts create mode 100644 docs/macos-bundle-updates.md create mode 100644 scripts/darwin-feed.mjs create mode 100644 tests-js/darwin-feed.test.mjs create mode 100644 tests-js/macos-publish-config.test.mjs diff --git a/.github/workflows/canary-release.yml b/.github/workflows/canary-release.yml index cfad8bf89d..c98d1d3dea 100644 --- a/.github/workflows/canary-release.yml +++ b/.github/workflows/canary-release.yml @@ -76,6 +76,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: python3 scripts/release.py --prune-canaries --publish --remote origin + - name: Install locked feed tooling + run: npm ci --workspaces=false --ignore-scripts --no-audit --no-fund - name: Prune R2 canary objects env: CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 020f3c3cda..0f49aa14e3 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -492,7 +492,7 @@ jobs: needs: validate runs-on: ${{ matrix.target.runner }} environment: release-signing - timeout-minutes: 900 + timeout-minutes: 180 strategy: fail-fast: false matrix: @@ -513,14 +513,12 @@ jobs: CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} steps: - - name: Disable Spotlight indexing and XProtect + - name: Disable Spotlight indexing for DMG staging # Spotlight indexes the freshly-mounted dmg staging image past # hdiutil's detach retries (per-VM, not a cross-job race). shell: bash run: | sudo mdutil -a -i off || true - sudo pkill -9 XProtect >/dev/null || true - while pgrep XProtect; do sleep 3; done # Check out the SHA the validate job admitted — never the tag ref, # which a force-push can move between jobs. This is the privileged @@ -529,6 +527,7 @@ jobs: with: ref: ${{ needs.validate.outputs.sha }} fetch-tags: true + fetch-depth: 0 - name: Resolve toolchain pins from pm/lock.json id: pins @@ -557,7 +556,7 @@ jobs: console.log(`builder=${eb}`) ' >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v7.0.0 # immutable release. safe to pin. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ steps.pins.outputs.node }} cache: npm @@ -693,21 +692,12 @@ jobs: uv tool install cmake==3.31.6 echo "$(uv tool dir --bin)" >> "$GITHUB_PATH" - - name: Derive the feed channel from the tag - id: channel - shell: bash - env: - TAG: ${{ inputs.tag }} - run: | - case "$TAG" in - *-canary.*) echo "channel=canary" >> "$GITHUB_OUTPUT" ;; - *) echo "channel=stable" >> "$GITHUB_OUTPUT" ;; - esac - - name: Build and package shell: bash - timeout-minutes: 900 + timeout-minutes: 160 env: + GITHUB_SHA: ${{ needs.validate.outputs.sha }} + GITHUB_REF_NAME: ${{ inputs.tag }} PYTHONUTF8: '1' # electron-osx-sign*/electron-notarize* keep the sign+notarize # phase visible: without them NOTHING logs between "signing @@ -747,11 +737,13 @@ jobs: fi for app in "${apps[@]}"; do codesign --verify --strict --verbose=2 "$app" + xcrun stapler validate "$app" spctl -a -vv -t exec "$app" echo "signed + notarized: $app" done - name: Rename the feed yml per arch + if: inputs.upload_release == true # electron-builder writes the channel feed yml (stable-mac.yml / # canary-mac.yml) with the SAME name on both legs; prefix the arch # so the publish job's merge-multiple download keeps both and @@ -763,24 +755,33 @@ jobs: shopt -s nullglob MATRIX_LABEL="${{ matrix.target.label }}" arch="${MATRIX_LABEL##*-}" - channel="${{ steps.channel.outputs.channel }}" - for f in apps/desktop/release/*-mac.yml; do - mv "$f" "apps/desktop/release/${arch}-${channel}-mac.yml" - echo "renamed: $(basename "$f") -> ${arch}-${channel}-mac.yml" - done - test -n "$(shopt -s nullglob; echo apps/desktop/release/*-mac.yml)" + channel="${{ needs.validate.outputs.channel }}" + f="apps/desktop/release/${channel}-mac.yml" + test -s "$f" + mv "$f" "apps/desktop/release/${arch}-${channel}-mac.yml" - - name: Upload artifacts + - name: Upload feed metadata + if: inputs.upload_release == true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - # The per-arch feed ymls for publish-darwin-updater to merge. The - # dmg/zip/blockmap binaries go straight to R2 from this leg. name: hermes-bundled-${{ matrix.target.label }}-${{ inputs.tag }} path: | apps/desktop/release/*-mac.yml retention-days: 30 if-no-files-found: error + - name: Retain non-publishing build artifacts + if: inputs.upload_release != true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: hermes-bundled-${{ matrix.target.label }}-${{ inputs.tag }} + path: | + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + apps/desktop/release/*.blockmap + retention-days: 30 + if-no-files-found: error + - name: Stage to Cloudflare R2 if: inputs.upload_release == true shell: bash @@ -1131,6 +1132,9 @@ jobs: # Privileged job: pin to the SHA validate admitted, not the tag. ref: ${{ needs.validate.outputs.sha }} + - name: Install locked feed tooling + run: npm ci --workspaces=false --ignore-scripts --no-audit --no-fund + - name: Download both darwin legs' feed ymls uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -1146,7 +1150,7 @@ jobs: run: | shopt -s nullglob ymls=(staged/*-mac.yml) - if [ ${#ymls[@]} -lt 2 ]; then + if [ ${#ymls[@]} -ne 2 ]; then echo "::error::expected the arm64 AND x64 feed ymls in the staged artifacts, found ${#ymls[@]}: ${ymls[*]}" exit 1 fi diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs index 67f55c3143..d433ea2c9f 100644 --- a/apps/desktop/electron-builder.config.cjs +++ b/apps/desktop/electron-builder.config.cjs @@ -12,6 +12,7 @@ const fs = require('node:fs') const path = require('node:path') +const feedContract = require('./update-feed.cjs') const { light, @@ -58,6 +59,9 @@ if (!/^\d+\.\d+\.\d+$/.test(electronVersion)) { throw new Error(`invalid electron version ${electronVersion} in package.json`) } +const macFeed = feedContract.darwinFeed(channel === 'canary' || channel === 'light-canary' ? 'canary' : 'stable', light) +const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/+$/, '') + /** @type {Configuration} */ module.exports = { electronVersion, @@ -119,6 +123,12 @@ module.exports = { unpack: ['**/*.node', '**/prebuilds/**', 'dist/**'] }, mac: { + // The afterSign hook owns notarization, including keychain-profile builds. + notarize: false, + // The packaged client reads this generated app-update.yml by default. + publish: publicUrl && !store + ? [{ provider: 'generic', url: `${publicUrl}/${macFeed.directory}/`, channel: macFeed.channel }] + : null, category: 'public.app-category.developer-tools', extendInfo: { CFBundleDisplayName: displayName, diff --git a/apps/desktop/electron/app-updater.ts b/apps/desktop/electron/app-updater.ts index d050b64c54..49f39e7e38 100644 --- a/apps/desktop/electron/app-updater.ts +++ b/apps/desktop/electron/app-updater.ts @@ -14,14 +14,7 @@ // Source installs never reach this module. The callers gate on the install // stamp first and fall through to the git-based update path. // -// NOTE: the darwin electron-updater arm (and its gate/channel/feed helpers — -// shouldUseAppUpdater, selectUpdaterArm, resolveUpdaterChannel, -// resolveFeedBaseUrl, feedSelection, describeFeedCheck) was ripped out in -// wt/darwin-updater: main.ts reimplemented the gate/channel/feed inline, so -// the module surface was test-only. To add macOS in-app updates back, restore -// the arm from git history (this file @ the parent of that commit) together -// with the `electron-updater` dependency and its test block — see the -// add-back plan in the wt/darwin-updater commit message. +// macOS packaged updates live in updater/mac.ts and updater/mac-client.ts. // // The win32 helpers are pure so vitest covers them; the impure pieces // (electron shell, payload python) are injected. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4fc154db2d..a2cb52373b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -412,6 +412,7 @@ import { createCheckoutStrategy } from './updater/checkout' import { ExternalStrategy } from './updater/external' +import { createMacStrategy } from './updater/mac-client' import { consumePendingRelaunch, registerUpdateRelaunch } from './updater/relaunch' import { startRelaunchWaiter } from './updater/relaunch-waiter' import { isHermesOwnedVenvDaemon } from './venv-holder-select' @@ -3099,18 +3100,20 @@ async function checkUpdates() { // mechanism once and delegate. Out-of-store MSIX asks the OS whether a // newer package is on the registered .appinstaller source; Store installs // report unsupported (the steward owns their update loop). - const bundledPayload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) + let strategy: UpdaterStrategy | null = null - if (bundledPayload) { - const strategy = resolveBundledUpdateStrategy(bundledPayload) + try { + strategy = resolvePackagedUpdateStrategy() - return strategy.check().catch(error => ({ + if (strategy) { return await strategy.check() } + } catch (error) { + return { supported: true, - mechanism: strategy.mechanism, + mechanism: strategy?.mechanism, error: 'check-failed', - message: error?.message || String(error), + message: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() - })) + } } // Checkout install: dispatch through the strategy layer — one mechanism, @@ -3199,21 +3202,51 @@ let updateInFlight = false // ── bundled / App Installer helpers ───────────────────────────────────────── /** - * Resolve the updater strategy for a BUNDLED install (payload present). - * Dispatch mirrors the pre-strategy ladder exactly: win32 out-of-store → - * App Installer arm; Store / other → external (unsupported). The checkout - * ladder below appliesUpdates' bundled branch stays in main.ts (it delegates - * through the same mechanism resolution via the wire's `mechanism` field). + * Keep the native updater instance alive across check, download and install. + * Its identity comes from the packaged app, not its optional Python payload. */ -function resolveBundledUpdateStrategy(bundledPayload) { +let packagedUpdateStrategy: UpdaterStrategy | undefined + +function resolvePackagedUpdateStrategy(): UpdaterStrategy | null { const mechanism = resolveUpdaterMechanism({ - isBundled: true, - isWindows: IS_WINDOWS, + isPackaged: IS_PACKAGED, + platform: process.platform, + payload: INSTALL_STAMP?.payload, + updateMechanism: BAKED_INSTALL_STAMP?.updateMechanism, isWindowsStore: isWindowsStore() }) + if (mechanism === 'windows-handoff' || mechanism === 'posix-handoff') { return null } + + if (packagedUpdateStrategy) { return packagedUpdateStrategy } + + if (mechanism === 'electron-updater') { + packagedUpdateStrategy = createMacStrategy({ + channel: resolveUpdaterChannelFromStamp(), + light: isLightVariant(), + feedBaseUrl: resolveDesktopFeedBaseUrl(), + appVersion: app.getVersion(), + log: rememberLog, + emitProgress: emitUpdateProgress, + beforeInstall: async () => { + isQuittingForHandoff = true + await Promise.all([teardownPrimaryBackendAndWait(), stopAllPoolBackends()]) + }, + onInstallFailure: async () => { + isQuittingForHandoff = false + updateInFlight = false + await startHermes() + } + }) + + return packagedUpdateStrategy + } + if (mechanism === 'app-installer') { - return new AppInstallerStrategy({ + const bundledPayload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) + + if (!bundledPayload) { return new ExternalStrategy() } + packagedUpdateStrategy = new AppInstallerStrategy({ python: bundledPayload.storePython, // The checker ships inside the payload's repo snapshot (git archive of // the committed tree): //apps/desktop/scripts/. @@ -3255,6 +3288,8 @@ function resolveBundledUpdateStrategy(bundledPayload) { }) }) }) + + return packagedUpdateStrategy } return new ExternalStrategy() @@ -3986,29 +4021,17 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { throw new Error('An update is already in progress.') } - // A bundled install ships its whole runtime as a sealed payload — there - // is no checkout to pull or venv to sync. New app release IS the update. - if (resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })) { - // Delegate to the bundled strategy: out-of-store MSIX runs the graceful - // teardown, hands the swap to the OS App Installer, registers the - // one-shot relaunch marker, and quits; anything else gets the manual - // card (reinstall the app release). - const strategy = resolveBundledUpdateStrategy( - resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) - ) - - return strategy.apply(opts) - } - - // Checkout install: dispatch through the strategy layer. The in-flight - // guard stays with the public entrypoint so no body path can start a - // second update. The flow lives in updater/checkout.ts. + const strategy = resolvePackagedUpdateStrategy() ?? resolveCheckoutUpdateStrategy() updateInFlight = true + let handedOff = false try { - return await resolveCheckoutUpdateStrategy().apply(opts) + const result = await strategy.apply(opts) + handedOff = result.handedOff === true + + return result } finally { - updateInFlight = false + if (!handedOff) { updateInFlight = false } } } diff --git a/apps/desktop/electron/updater/index.ts b/apps/desktop/electron/updater/index.ts index 90fe021fc7..acd5efcc04 100644 --- a/apps/desktop/electron/updater/index.ts +++ b/apps/desktop/electron/updater/index.ts @@ -12,13 +12,14 @@ // manual checkout with no staged updater — the user runs // `hermes update` themselves. // -// The mechanism is resolved ONCE from runtime facts (payload presence, -// platform, store flag) by resolveUpdaterMechanism — a pure function, unit +// The mechanism is resolved from the packaged identity, platform and store +// flag by resolveUpdaterMechanism — a pure function, unit // tested — and every strategy reports it on the wire so the renderer can // tailor copy per mechanism without probing the install shape itself. export type UpdaterMechanism = | 'app-installer' + | 'electron-updater' | 'external' | 'windows-handoff' | 'posix-handoff' @@ -26,24 +27,29 @@ export type UpdaterMechanism = /** The facts the mechanism dispatch keys on. Pure data — injectable for tests. */ export interface MechanismFacts { - /** A bundled payload ships inside this artifact (sealed runtime). */ - isBundled: boolean - isWindows: boolean + isPackaged: boolean + platform: NodeJS.Platform + payload: 'bundled' | 'light' | 'bootstrap' | undefined + updateMechanism: 'self' | 'external' | 'electron-updater' | undefined /** This process is a Microsoft Store deployment. */ isWindowsStore: boolean } /** - * Resolve which mechanism owns updates for this install. Precedence mirrors - * the historical checkUpdates/applyUpdates ladder in main.ts exactly: - * payload-probe first, store-flag second, platform third. Behavior-preserving. + * The stamp names the artifact owner. A missing payload must never turn a + * packaged app into a checkout, and Light needs no payload to update itself. */ export function resolveUpdaterMechanism(facts: MechanismFacts): UpdaterMechanism { - if (facts.isBundled) { - return facts.isWindows && !facts.isWindowsStore ? 'app-installer' : 'external' + if (facts.isPackaged && (facts.payload === 'bundled' || facts.payload === 'light')) { + if (facts.isWindowsStore) { return 'external' } + + if (facts.platform === 'win32') { return 'app-installer' } + + return facts.platform === 'darwin' && facts.updateMechanism === 'electron-updater' + ? 'electron-updater' : 'external' } - return facts.isWindows ? 'windows-handoff' : 'posix-handoff' + return facts.platform === 'win32' ? 'windows-handoff' : 'posix-handoff' } /** The status shape main.ts already sends over `hermes:updates:check`. */ diff --git a/apps/desktop/electron/updater/mac-client.test.ts b/apps/desktop/electron/updater/mac-client.test.ts new file mode 100644 index 0000000000..a15ee3644a --- /dev/null +++ b/apps/desktop/electron/updater/mac-client.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { client } = vi.hoisted(() => ({ + client: { + autoDownload: true, autoInstallOnAppQuit: true, autoRunAppAfterInstall: false, + channel: '', allowPrerelease: true, allowDowngrade: true, + on: vi.fn(), setFeedURL: vi.fn(), checkForUpdates: vi.fn(async () => null) + } +})) + +vi.mock('electron', () => ({ autoUpdater: {} })) +vi.mock('electron-updater', () => ({ default: { MacUpdater: class { constructor() { return client } } } })) + +import { createMacStrategy } from './mac-client' + +afterEach(() => vi.clearAllMocks()) + +function deps(feedBaseUrl = '', light = false, channel: 'stable' | 'canary' = 'stable') { + return { channel, light, feedBaseUrl, appVersion: '0.28.0', log: vi.fn(), emitProgress: vi.fn(), beforeInstall: vi.fn(), onInstallFailure: vi.fn() } +} + +describe('macOS client wiring', () => { + it('uses the generated provider by default and forbids implicit installs or downgrades', async () => { + const strategy = createMacStrategy(deps()) + expect(client.setFeedURL).not.toHaveBeenCalled() + await expect(strategy.check()).rejects.toThrow('not active') + expect(client.autoDownload).toBe(false) + expect(client.autoInstallOnAppQuit).toBe(false) + expect(client.autoRunAppAfterInstall).toBe(true) + expect(client.allowDowngrade).toBe(false) + expect(client.channel).toBe('stable') + expect(client.allowPrerelease).toBe(false) + }) + + it('overrides the provider with the same variant/channel path as the publisher', () => { + createMacStrategy(deps('https://updates.example/', true, 'canary')) + expect(client.setFeedURL).toHaveBeenCalledWith({ provider: 'generic', url: 'https://updates.example/releases/darwin/light/canary/', channel: 'canary' }) + expect(client.allowPrerelease).toBe(true) + expect(() => createMacStrategy(deps('http://untrusted.example'))).toThrow('HTTPS') + }) +}) diff --git a/apps/desktop/electron/updater/mac-client.ts b/apps/desktop/electron/updater/mac-client.ts new file mode 100644 index 0000000000..15da592ee4 --- /dev/null +++ b/apps/desktop/electron/updater/mac-client.ts @@ -0,0 +1,37 @@ +import { autoUpdater as nativeUpdater } from 'electron' +import electronUpdater from 'electron-updater' + +import feedContract from '../../update-feed.cjs' + +import { MacStrategy, type MacStrategyDeps, prepareMacInstall } from './mac' + +export interface MacClientDeps extends Omit { + light: boolean + feedBaseUrl: string + log: (message: string) => void +} + +export function createMacStrategy(deps: MacClientDeps): MacStrategy { + const feed = feedContract.darwinFeed(deps.channel, deps.light) + const updater = new electronUpdater.MacUpdater() + updater.autoDownload = false + updater.autoInstallOnAppQuit = false + updater.autoRunAppAfterInstall = true + updater.channel = feed.channel + updater.allowPrerelease = feed.allowPrerelease + // Setting channel enables downgrades in electron-updater. This app never does. + updater.allowDowngrade = false + updater.on('error', error => deps.log(`macOS updater: ${error.message}`)) + + if (deps.feedBaseUrl) { + const base = new URL(deps.feedBaseUrl) + + if (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) { + throw new Error('The update feed must use HTTPS or a loopback HTTP address.') + } + + updater.setFeedURL({ provider: 'generic', url: `${base.href.replace(/\/+$/, '')}/${feed.directory}/`, channel: feed.channel }) + } + + return new MacStrategy({ ...deps, updater, prepareInstall: () => prepareMacInstall(nativeUpdater) }) +} diff --git a/apps/desktop/electron/updater/mac.test.ts b/apps/desktop/electron/updater/mac.test.ts new file mode 100644 index 0000000000..19ec867cb1 --- /dev/null +++ b/apps/desktop/electron/updater/mac.test.ts @@ -0,0 +1,115 @@ +import { EventEmitter } from 'node:events' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { MacStrategy, type MacStrategyDeps, prepareMacInstall } from './mac' + +function fixture() { + const events: string[] = [] + const emitter = new EventEmitter() + const info = { version: '0.29.0', files: [], releaseDate: '', path: '', sha512: '' } + + const deps: MacStrategyDeps = { + updater: { + checkForUpdates: vi.fn(async () => { + events.push('check') + + return { isUpdateAvailable: true, updateInfo: info, versionInfo: info } + }), + downloadUpdate: vi.fn(async () => { events.push('download'); + + return [] }), + quitAndInstall: vi.fn(() => { events.push('install') }), + on: emitter.on.bind(emitter) as MacStrategyDeps['updater']['on'], + removeListener: emitter.removeListener.bind(emitter) as MacStrategyDeps['updater']['removeListener'] + }, + channel: 'canary', + appVersion: '0.28.0', + prepareInstall: vi.fn(async () => { events.push('verify') }), + beforeInstall: vi.fn(async () => { events.push('stop') }), + onInstallFailure: vi.fn(async () => { events.push('restore') }), + emitProgress: vi.fn() + } + + return { deps, events, emitter, strategy: new MacStrategy(deps) } +} + +afterEach(() => vi.useRealTimers()) + +describe('macOS strategy', () => { + it('checks the release, verifies before teardown, and installs once', async () => { + const { strategy, events, emitter } = fixture() + expect(await strategy.check()).toMatchObject({ channel: 'canary', latestTag: 'v0.29.0', updateAvailable: true }) + events.length = 0 + expect(await strategy.apply()).toMatchObject({ ok: true, handedOff: true }) + expect(events).toEqual(['check', 'download', 'verify', 'stop', 'install']) + expect(emitter.listenerCount('download-progress')).toBe(0) + }) + + it.each(['downloadUpdate', 'prepareInstall'] as const)('keeps backends alive on %s failure', async failure => { + const { deps, strategy, events, emitter } = fixture() + vi.mocked(failure === 'downloadUpdate' ? deps.updater.downloadUpdate : deps.prepareInstall) + .mockRejectedValueOnce(new Error('invalid update')) + await expect(strategy.apply()).rejects.toThrow('invalid update') + expect(events).not.toContain('stop') + expect(events).not.toContain('install') + expect(emitter.listenerCount('download-progress')).toBe(0) + }) + + it('does not install when the provider reports no newer release', async () => { + const { deps, strategy, events } = fixture() + const info = { version: '0.27.0', files: [], releaseDate: '', path: '', sha512: '' } + vi.mocked(deps.updater.checkForUpdates).mockResolvedValue({ + isUpdateAvailable: false, updateInfo: info, versionInfo: info + }) + await strategy.apply() + expect(events).toEqual([]) + expect(deps.updater.downloadUpdate).not.toHaveBeenCalled() + }) + + it('restores the backend if install handoff throws', async () => { + const { deps, strategy, events } = fixture() + vi.mocked(deps.updater.quitAndInstall).mockImplementation(() => { throw new Error('handoff failed') }) + await expect(strategy.apply()).rejects.toThrow('handoff failed') + expect(events.slice(-2)).toEqual(['stop', 'restore']) + }) + + it('rejects simultaneous apply calls', async () => { + const { deps, strategy } = fixture() + let release!: () => void + vi.mocked(deps.prepareInstall).mockImplementation(() => new Promise(resolve => { release = resolve })) + const applying = strategy.apply() + await vi.waitFor(() => expect(deps.prepareInstall).toHaveBeenCalledOnce()) + await expect(strategy.apply()).rejects.toThrow('already in progress') + await expect(strategy.check()).rejects.toThrow('already in progress') + release() + await applying + }) +}) + +describe('native signature verification', () => { + it('waits for native readiness and removes both listeners', async () => { + const native = Object.assign(new EventEmitter(), { checkForUpdates: vi.fn() }) + let ready = false + const pending = prepareMacInstall(native).then(() => { ready = true }) + await Promise.resolve() + expect(ready).toBe(false) + native.emit('update-downloaded') + await pending + expect(native.listenerCount('error')).toBe(0) + expect(native.listenerCount('update-downloaded')).toBe(0) + }) + + it('surfaces native rejection and bounds a missing readiness event', async () => { + vi.useFakeTimers() + const native = Object.assign(new EventEmitter(), { checkForUpdates: vi.fn() }) + const rejected = expect(prepareMacInstall(native)).rejects.toThrow('bad signature') + native.emit('error', new Error('bad signature')) + await rejected + const timeout = expect(prepareMacInstall(native, 2000)).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(2000) + await timeout + expect(native.listenerCount('error')).toBe(0) + expect(native.listenerCount('update-downloaded')).toBe(0) + }) +}) diff --git a/apps/desktop/electron/updater/mac.ts b/apps/desktop/electron/updater/mac.ts new file mode 100644 index 0000000000..12ddfc17f7 --- /dev/null +++ b/apps/desktop/electron/updater/mac.ts @@ -0,0 +1,104 @@ +import type { AppUpdater } from 'electron-updater' + +import type { UpdaterApplyResultWire, UpdaterStatusWire, UpdaterStrategy } from './index' + +export interface MacStrategyDeps { + updater: Pick + channel: 'stable' | 'canary' + appVersion: string + /** Squirrel verifies the signed app before any backend is stopped. */ + prepareInstall: () => Promise + beforeInstall: () => Promise + onInstallFailure: () => Promise + emitProgress: (payload: { stage: string; message: string; percent: number | null }) => void +} + +export class MacStrategy implements UpdaterStrategy { + readonly mechanism = 'electron-updater' as const + private applying = false + + constructor(private readonly deps: MacStrategyDeps) {} + + async check(): Promise { + if (this.applying) { throw new Error('An update is already in progress.') } + + return this.checkRelease() + } + + private async checkRelease(): Promise { + const result = await this.deps.updater.checkForUpdates() + + if (!result) { throw new Error('The macOS updater is not active for this app.') } + + return { + supported: true, + mechanism: this.mechanism, + currentVersion: this.deps.appVersion, + channel: this.deps.channel, + latestTag: `v${result.updateInfo.version}`, + updateAvailable: result.isUpdateAvailable, + fetchedAt: Date.now() + } + } + + async apply(): Promise { + if (this.applying) { throw new Error('An update is already in progress.') } + this.applying = true + let stopped = false + + const progress = ({ percent }: { percent: number }): void => { + this.deps.emitProgress({ stage: 'fetch', message: 'Downloading the Hermes update.', percent }) + } + + this.deps.updater.on('download-progress', progress) + + try { + const status = await this.checkRelease() + + if (!status.updateAvailable) { return { ok: true, mechanism: this.mechanism } } + await this.deps.updater.downloadUpdate() + this.deps.emitProgress({ stage: 'prepare', message: 'Verifying the signed macOS update.', percent: null }) + await this.deps.prepareInstall() + stopped = true + await this.deps.beforeInstall() + this.deps.emitProgress({ stage: 'restart', message: 'Restarting Hermes to install the update.', percent: 100 }) + this.deps.updater.quitAndInstall() + + return { ok: true, bundled: true, handedOff: true, mechanism: this.mechanism } + } catch (error) { + if (stopped) { await this.deps.onInstallFailure() } + throw error + } finally { + this.deps.updater.removeListener('download-progress', progress) + this.applying = false + } + } +} + +export interface NativeMacUpdater { + once(event: 'update-downloaded', listener: () => void): unknown + once(event: 'error', listener: (error: Error) => void): unknown + removeListener(event: 'update-downloaded', listener: () => void): unknown + removeListener(event: 'error', listener: (error: Error) => void): unknown + checkForUpdates(): void +} + +/** Download completion alone does not mean Squirrel accepted the signature. */ +export function prepareMacInstall(native: NativeMacUpdater, timeoutMs = 120_000): Promise { + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timer) + native.removeListener('error', failed) + native.removeListener('update-downloaded', ready) + } + + const failed = (error: Error): void => { cleanup(); reject(error) } + + const ready = (): void => { cleanup(); resolve() } + const timer = setTimeout(() => failed(new Error('macOS update verification timed out.')), timeoutMs) + native.once('error', failed) + native.once('update-downloaded', ready) + + try { native.checkForUpdates() } catch (error) { failed(error as Error) } + }) +} diff --git a/apps/desktop/electron/updater/updater.test.ts b/apps/desktop/electron/updater/updater.test.ts index 20a7491132..18aa9b5f87 100644 --- a/apps/desktop/electron/updater/updater.test.ts +++ b/apps/desktop/electron/updater/updater.test.ts @@ -9,31 +9,25 @@ import { consumePendingRelaunch, PENDING_RELAUNCH_FILENAME, registerUpdateRelaun import { resolveUpdaterMechanism } from './index' -describe('resolveUpdaterMechanism — precedence', () => { - it('bundled win32 out-of-store → app-installer', () => { - expect(resolveUpdaterMechanism({ isBundled: true, isWindows: true, isWindowsStore: false })).toBe('app-installer') +describe('resolveUpdaterMechanism — install ownership', () => { + it.each(['bundled', 'light'] as const)('macOS %s updates without probing for Python', payload => { + expect(resolveUpdaterMechanism({ isPackaged: true, payload, platform: 'darwin', updateMechanism: 'electron-updater', isWindowsStore: false })).toBe('electron-updater') }) - it('bundled win32 Store → external (steward owns updates)', () => { - expect(resolveUpdaterMechanism({ isBundled: true, isWindows: true, isWindowsStore: true })).toBe('external') + it.each([ + ['win32', false, 'app-installer'], + ['win32', true, 'external'], + ['linux', false, 'external'], + ['darwin', false, 'external'] + ] as const)('preserves %s steward ownership (store=%s)', (platform, isWindowsStore, expected) => { + expect(resolveUpdaterMechanism({ isPackaged: true, payload: 'bundled', platform, isWindowsStore, updateMechanism: 'external' })).toBe(expected) }) - it('bundled posix → external', () => { - expect(resolveUpdaterMechanism({ isBundled: true, isWindows: false, isWindowsStore: false })).toBe('external') - }) - - it('checkout win32 → windows-handoff', () => { - expect(resolveUpdaterMechanism({ isBundled: false, isWindows: true, isWindowsStore: false })).toBe('windows-handoff') - }) - - it('checkout posix → posix-handoff', () => { - expect(resolveUpdaterMechanism({ isBundled: false, isWindows: false, isWindowsStore: false })).toBe('posix-handoff') - }) - - it('store flag never downgrades a checkout (probe only fires for bundled)', () => { - // isWindowsStore on a checkout is meaningless; the resolver must not - // route a win32 checkout to external on a stray true. - expect(resolveUpdaterMechanism({ isBundled: false, isWindows: true, isWindowsStore: true })).toBe('windows-handoff') + it.each(['win32', 'darwin', 'linux'] as const)('dev and bootstrap %s retain checkout updates', platform => { + const facts = { isPackaged: true, platform, payload: 'bootstrap' as const, updateMechanism: 'self' as const, isWindowsStore: false } + const expected = platform === 'win32' ? 'windows-handoff' : 'posix-handoff' + expect(resolveUpdaterMechanism(facts)).toBe(expected) + expect(resolveUpdaterMechanism({ ...facts, isPackaged: false, payload: 'bundled' })).toBe(expected) }) }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7546a1e41f..4f21683318 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -120,6 +120,7 @@ "dnd-core": "14.0.1", "dompurify": "3.4.13", "driver.js": "1.8.0", + "electron-updater": "6.8.9", "emojibase-data": "16.0.3", "fflate": "0.8.3", "frimousse": "0.3.0", diff --git a/apps/desktop/scripts/write-build-stamp.mjs b/apps/desktop/scripts/write-build-stamp.mjs index ecd7db35bf..3981a7e929 100644 --- a/apps/desktop/scripts/write-build-stamp.mjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -186,7 +186,7 @@ function main() { * Dev/local builds (no variant) keep the old shape; installShape() then treats * them as checkout, which is correct for a dev run. */ -export function buildStampPayload(stamp, env = process.env) { +export function buildStampPayload(stamp, env = process.env, platform = process.platform) { const variant = (env.HERMES_DESKTOP_VARIANT || "").trim() const base = { schemaVersion: STAMP_SCHEMA_VERSION, @@ -203,7 +203,7 @@ export function buildStampPayload(stamp, env = process.env) { payload: variant === "store" ? "bundled" : variant || "bootstrap", store: variant === "store", distribution: "desktop-app", - updateMechanism: "external", // sealed artifact — the OS/steward owns updates + updateMechanism: platform === 'darwin' && ['bundled', 'light'].includes(variant) ? 'electron-updater' : 'external', tag: env.HERMES_PAYLOAD_TAG || null } } diff --git a/apps/desktop/scripts/write-build-stamp.test.mjs b/apps/desktop/scripts/write-build-stamp.test.mjs index 76ebfe866b..af6d5ebc9c 100644 --- a/apps/desktop/scripts/write-build-stamp.test.mjs +++ b/apps/desktop/scripts/write-build-stamp.test.mjs @@ -117,7 +117,7 @@ test('buildStampPayload with bundled variant stamps payload bundled, store false const payload = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: 'bundled', HERMES_PAYLOAD_TAG: 'v0.27.1-canary.20260901072553' - }) + }, 'win32') assert.equal(payload.payload, 'bundled') assert.equal(payload.store, false) assert.equal(payload.distribution, 'desktop-app') @@ -125,6 +125,13 @@ test('buildStampPayload with bundled variant stamps payload bundled, store false assert.equal(payload.tag, 'v0.27.1-canary.20260901072553') }) +test('macOS bundles and Light declare app-owned updates, never Store builds', () => { + for (const variant of ['bundled', 'light', 'store']) { + const stamp = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: variant }, 'darwin') + assert.equal(stamp.updateMechanism, variant === 'store' ? 'external' : 'electron-updater') + } +}) + test('buildStampPayload with store variant stamps payload bundled, store true', () => { const payload = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: 'store', diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a0148241b2..3d0f3c69dc 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -711,6 +711,7 @@ export interface DesktopUpdateCommit { export type UpdaterMechanismClient = | 'app-installer' + | 'electron-updater' | 'external' | 'windows-handoff' | 'posix-handoff' diff --git a/apps/desktop/src/lib/update-copy.test.ts b/apps/desktop/src/lib/update-copy.test.ts index 0bac332aa0..cd066756ff 100644 --- a/apps/desktop/src/lib/update-copy.test.ts +++ b/apps/desktop/src/lib/update-copy.test.ts @@ -77,4 +77,9 @@ describe('resolveUpdateCopy', () => { const r = resolveUpdateCopy({ target: 'client', shownItems: 5, mechanism: 'windows-handoff', copy }) expect(r.body).toBe(copy.availableBody) }) + + it('macOS feed updates name the release without Windows or commit vocabulary', () => { + expect(resolveUpdateCopy({ target: 'client', shownItems: 0, mechanism: 'electron-updater', latestTag: 'v0.29.0-canary.20260906000000', copy }).body) + .toBe(copy.availableBodyRelease('v0.29.0-canary.20260906000000')) + }) }) diff --git a/apps/desktop/src/lib/update-copy.ts b/apps/desktop/src/lib/update-copy.ts index fc31831899..01536d4bfb 100644 --- a/apps/desktop/src/lib/update-copy.ts +++ b/apps/desktop/src/lib/update-copy.ts @@ -40,7 +40,7 @@ export interface ResolveUpdateCopyInput { * 'app-installer': the OS App Installer owns the apply (out-of-store MSIX) * — the body names Windows as the finisher, never commit vocabulary. */ - mechanism?: 'app-installer' | 'external' | 'windows-handoff' | 'posix-handoff' | 'manual' + mechanism?: 'app-installer' | 'electron-updater' | 'external' | 'windows-handoff' | 'posix-handoff' | 'manual' copy: UpdateCopyStrings } @@ -66,7 +66,7 @@ export function resolveUpdateCopy({ return { title, body: latestTag ? copy.availableBodyRelease(latestTag) : copy.availableBodyAppInstaller } } - if (channel === 'stable') { + if (channel === 'stable' || mechanism === 'electron-updater') { // No-changelog copy would be wrong here: the absence of commit rows is // structural on a release feed, not a degraded install type. return { title, body: latestTag ? copy.availableBodyRelease(latestTag) : copy.availableBody } diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json index 54d6b3f084..a9741faebd 100644 --- a/apps/desktop/tsconfig.electron.json +++ b/apps/desktop/tsconfig.electron.json @@ -16,6 +16,6 @@ "rootDir": "..", "outDir": "build/electron-types" }, - "include": ["electron", "product-identity.cjs", "product-identity.d.cts", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"], + "include": ["electron", "product-identity.cjs", "product-identity.d.cts", "update-feed.cjs", "update-feed.d.cts", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"], "exclude": ["src", "electron/**/*.e2e.mts"] } diff --git a/apps/desktop/update-feed.cjs b/apps/desktop/update-feed.cjs new file mode 100644 index 0000000000..ae1b6db014 --- /dev/null +++ b/apps/desktop/update-feed.cjs @@ -0,0 +1,36 @@ +'use strict' + +// apps/desktop/update-feed.cjs — the ONE source of truth for the Darwin +// (macOS) electron-updater feed layout, shared by the desktop runtime +// (generic provider base URL) and the release pipeline +// (scripts/r2-release.mjs finalize). Pure CJS: requireable from both the +// app bundle and ESM scripts via createRequire. No dependencies. +// +// darwinFeed('stable') → { directory: 'releases/darwin/stable', +// channel: 'stable', +// fileName: 'stable-mac.yml', +// allowPrerelease: false } +// darwinFeed('canary', true) → { directory: 'releases/darwin/light/canary', +// channel: 'canary', +// fileName: 'canary-mac.yml', +// allowPrerelease: true } +// +// A client composes its feed URL as PUBLIC_URL + '/' + feed.directory + +// '/' + feed.fileName; the producer publishes the manifest at exactly that +// key. There is no placeholder default URL — the caller supplies the base. + +const CHANNELS = ['stable', 'canary'] + +function darwinFeed(channel, light = false) { + if (!CHANNELS.includes(channel)) { + throw new TypeError(`darwinFeed: unknown channel ${JSON.stringify(channel)} (expected stable|canary)`) + } + return { + directory: light ? `releases/darwin/light/${channel}` : `releases/darwin/${channel}`, + channel, + fileName: `${channel}-mac.yml`, + allowPrerelease: channel === 'canary', + } +} + +module.exports = { darwinFeed } diff --git a/apps/desktop/update-feed.d.cts b/apps/desktop/update-feed.d.cts new file mode 100644 index 0000000000..a995cfd929 --- /dev/null +++ b/apps/desktop/update-feed.d.cts @@ -0,0 +1,20 @@ +interface DarwinFeed { + /** Feed directory key under the public bucket, no trailing slash. + * e.g. "releases/darwin/stable" | "releases/darwin/light/canary" */ + directory: string + /** Normalized channel. "stable" | "canary" */ + channel: 'stable' | 'canary' + /** electron-updater manifest filename. e.g. "stable-mac.yml" */ + fileName: string + /** True only for the canary channel. */ + allowPrerelease: boolean +} + +/** + * Feed layout contract shared by the desktop runtime and the release + * pipeline. `light` selects the Light-variant feed directory. + * The generic-provider feed URL is PUBLIC_URL + '/' + directory + '/' + fileName. + */ +declare function darwinFeed(channel: 'stable' | 'canary', light?: boolean): DarwinFeed + +export = { darwinFeed } diff --git a/docs/macos-bundle-updates.md b/docs/macos-bundle-updates.md new file mode 100644 index 0000000000..0e2511ecd9 --- /dev/null +++ b/docs/macos-bundle-updates.md @@ -0,0 +1,61 @@ +# macOS bundle updates + +The packaged macOS app uses `electron-updater`. The bundled and Light stamps +name that owner. Development and bootstrap installs keep checkout updates. +Windows App Installer and Store ownership are unchanged. + +## Feed contract + +`apps/desktop/update-feed.cjs` defines each channel directory and filename. +The builder writes that URL into `app-update.yml`. The client uses this file +unless `updates.desktop_feed_base_url` supplies an explicit bucket-base URL. + +- Stable: `releases/darwin/stable/stable-mac.yml` +- Canary: `releases/darwin/canary/canary-mac.yml` +- Light: the same paths with `light/` between `darwin/` and the channel. +- Artifacts: `releases/tag/TAG/FILENAME`, shared by download links and feeds. + +The current workflow builds the bundled variant, on ARM64 and Intel runners. +Light has separate client/feed routing but no release matrix leg in this change. + +`r2-release.mjs finalize` requires one metadata file for each architecture, +named `arm64-CHANNEL-mac.yml` and `x64-CHANNEL-mac.yml`. It rejects wrong +versions, variants, architectures, hashes and inconsistent legacy path fields. +Each referenced ZIP/DMG is streamed back and checked against its SHA-512 and +size. Publication checks the live version, conditionally replaces its ETag, +and reads back the resulting feed. Same-tag macOS artifacts cannot be overwritten +with different bytes. Mutable feeds use `Cache-Control: no-store`. +Canary retention protects the artifacts and blockmaps referenced by live feeds. +An unreadable feed prevents pruning. + +## Client lifecycle + +Checks never download automatically. Apply rechecks the release, downloads it, +and waits for Squirrel.Mac to accept the signed app. Only then does Hermes stop +its app-owned backends and request installation/relaunch. Unrelated quits do +not trigger installation. Downloads and native-verification failures leave +backends running. Concurrent checks cannot replace an apply operation's target. +The existing checkout updater never mutates the sealed app bundle. + +## Release environment + +The existing `release-signing` environment supplies: + +- `CSC_LINK` and `CSC_KEY_PASSWORD`: Developer ID Application signing identity. +- `APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`: notarization. +- `CLOUDFLARE_R2_ACCOUNT_ID`, `CLOUDFLARE_R2_ACCESS_KEY_ID`, + `CLOUDFLARE_R2_SECRET_ACCESS_KEY`: bucket access secrets. +- `CLOUDFLARE_R2_BUCKET`, `CLOUDFLARE_R2_PUBLIC_URL`: repository/environment vars. + +Publishing requires the Apple credentials. The existing after-sign hook owns +notarization, so electron-builder's second notarization path is disabled. +The publish gate verifies the signature, stapled ticket and Gatekeeper assessment. +The Darwin publish job waits for both native builds and serializes channel writes. + +## Verification limits + +Local tests exercise the strategy, native-event ordering, feed validation, +conditional publication and retention with injected OS/network boundaries. +The desktop TypeScript and JavaScript build run on the development host. +These checks are not proof of a signed macOS install or an actual app replacement. +No E2E work, release dispatch or public feed publication is included here. diff --git a/package-lock.json b/package-lock.json index d7693d1c79..67a21bac8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,10 @@ "web", "tests-js" ], + "dependencies": { + "js-yaml": "4.3.1", + "semver": "7.7.4" + }, "devDependencies": { "@eslint/js": "9.39.5", "eslint-plugin-perfectionist": "5.10.0", @@ -107,6 +111,7 @@ "dnd-core": "14.0.1", "dompurify": "3.4.13", "driver.js": "1.8.0", + "electron-updater": "6.8.9", "emojibase-data": "16.0.3", "fflate": "0.8.3", "frimousse": "0.3.0", @@ -233,19 +238,6 @@ "url": "https://opencollective.com/babel" } }, - "apps/desktop/node_modules/@babel/core/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "apps/desktop/node_modules/@babel/generator": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", @@ -281,19 +273,6 @@ "node": "^22.18.0 || >=24.11.0" } }, - "apps/desktop/node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "apps/desktop/node_modules/@babel/helper-globals": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", @@ -434,6 +413,16 @@ "global-agent": "^3.0.0" } }, + "apps/desktop/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" + } + }, "apps/desktop/node_modules/@rolldown/plugin-babel": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.3.tgz", @@ -862,6 +851,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/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/@babel/generator": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", @@ -896,6 +895,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/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/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -1780,19 +1789,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@electron/get/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/notarize": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", @@ -1840,19 +1836,6 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@electron/osx-sign/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/rebuild": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", @@ -2005,7 +1988,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -2022,7 +2004,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2039,7 +2020,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2056,7 +2036,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2073,7 +2052,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2090,7 +2068,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2107,7 +2084,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2124,7 +2100,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2141,7 +2116,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2158,7 +2132,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2175,7 +2148,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2192,7 +2164,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2209,7 +2180,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2226,7 +2196,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2243,7 +2212,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2260,7 +2228,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2277,7 +2244,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2294,7 +2260,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2311,7 +2276,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2328,7 +2292,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2345,7 +2308,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2362,7 +2324,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -2379,7 +2340,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -2396,7 +2356,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2413,7 +2372,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2430,7 +2388,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3260,19 +3217,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", @@ -3401,19 +3345,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@observablehq/plot": { "version": "0.6.17", "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", @@ -6980,19 +6911,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", @@ -7523,19 +7441,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/app-builder-lib/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7603,7 +7508,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -10121,6 +10025,70 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -11402,19 +11370,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/get-windows/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/get-windows/node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -11499,20 +11454,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globals": { "version": "17.7.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", @@ -12702,7 +12643,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "dev": true, "funding": [ { "type": "github", @@ -12924,7 +12864,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/leva": { @@ -13267,6 +13206,19 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -13353,6 +13305,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", @@ -14789,19 +14751,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -14818,19 +14767,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-api-version/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -14912,19 +14848,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/undici": { "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", @@ -16791,7 +16714,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -16833,13 +16755,15 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "devOptional": true, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -17027,19 +16951,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", @@ -17685,6 +17596,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -17890,7 +17807,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -19707,19 +19623,6 @@ "engines": { "node": "20 || >=22" } - }, - "web/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } } } } diff --git a/package.json b/package.json index 406b73b205..0ea4b21a3c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "tests-js" ], "scripts": { - "postinstall": "echo '\u2705 Node dependencies installed. Run: python run_agent.py --help'", + "postinstall": "echo '✅ Node dependencies installed. Run: python run_agent.py --help'", "install:root": "npm install --workspaces=false", "install:web": "npm install --workspace web", "install:tui": "npm install --workspace ui-tui", @@ -36,11 +36,11 @@ "homepage": "https://github.com/NousResearch/Hermes-Agent#readme", "devDependencies": { "@eslint/js": "9.39.5", - "typescript-eslint": "8.64.0", "eslint-plugin-perfectionist": "5.10.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-unused-imports": "4.4.1", - "globals": "17.7.0" + "globals": "17.7.0", + "typescript-eslint": "8.64.0" }, "overrides": { "lodash": "4.18.1", @@ -71,5 +71,9 @@ "fsevents@2.3.2": true, "fsevents@2.3.3": true, "get-windows@9.3.0": true + }, + "dependencies": { + "js-yaml": "4.3.1", + "semver": "7.7.4" } } diff --git a/scripts/darwin-feed.mjs b/scripts/darwin-feed.mjs new file mode 100644 index 0000000000..c8e2597e76 --- /dev/null +++ b/scripts/darwin-feed.mjs @@ -0,0 +1,90 @@ +import { isDeepStrictEqual } from 'node:util' +import yaml from 'js-yaml' +import semver from 'semver' +import feedContract from '../apps/desktop/update-feed.cjs' + +const { darwinFeed } = feedContract +const arches = ['arm64', 'x64'] +const hashPattern = /^[A-Za-z0-9+/]{86}==$/ + +export function parseMacFeed(text) { + const feed = yaml.load(text) + if (!feed || typeof feed !== 'object' || !semver.valid(feed.version) || !Array.isArray(feed.files) || !feed.files.length) { + throw new Error('Invalid macOS update feed') + } + for (const file of feed.files) { + if (typeof file.url !== 'string' || !hashPattern.test(file.sha512) || !Number.isSafeInteger(file.size) || file.size <= 0) { + throw new Error('Invalid macOS artifact metadata') + } + } + if (feed.path && !feed.files.some(file => file.url === feed.path && file.sha512 === feed.sha512)) { + throw new Error('Legacy feed path/hash disagrees with files') + } + return feed +} + +export function macFeedReferences(text) { + const feed = parseMacFeed(text) + const references = [] + for (const file of feed.files) { + // Only our immutable artifact namespace is eligible for publication or pruning. + if (!/^\/releases\/tag\/v[0-9A-Za-z.+-]+\/[0-9A-Za-z._+-]+\.(zip|dmg)$/.test(file.url)) { + throw new Error(`Invalid macOS artifact path: ${file.url}`) + } + const key = file.url.slice(1) + references.push(key, `${key}.blockmap`) + } + return references +} + +export function mergeMacFeeds(legs, tag, light = false) { + const version = tag?.startsWith('v') ? tag.slice(1) : '' + if (!semver.valid(version) || !/^v\d+\.\d+\.\d+(?:-canary\.\d{14})?$/.test(tag)) { + throw new Error('Invalid macOS release tag') + } + const selection = darwinFeed(version.includes('-canary.') ? 'canary' : 'stable', light) + const expected = arches.map(arch => `${arch}-${selection.fileName}`) + if (!isDeepStrictEqual(Object.keys(legs).sort(), [...expected].sort())) { + throw new Error('Expected exactly one ARM64 and one x64 macOS feed') + } + const files = new Map() + let first + for (const [index, name] of expected.entries()) { + const leg = parseMacFeed(legs[name]) + if (leg.version !== version) { throw new Error(`Feed version does not match ${tag}`) } + const prefix = `${light ? 'HermesLight' : 'HermesBundled'}-${version}-mac-${arches[index]}` + if (!leg.files.some(file => file.url === `${prefix}.zip`)) { throw new Error(`Missing native ZIP for ${arches[index]}`) } + for (const file of leg.files) { + if (![`${prefix}.zip`, `${prefix}.dmg`].includes(file.url)) { throw new Error(`Wrong variant or architecture: ${file.url}`) } + const prior = files.get(file.url) + const rewritten = { ...file, url: `/releases/tag/${tag}/${file.url}` } + if (prior && !isDeepStrictEqual(prior, rewritten)) { throw new Error(`Conflicting artifact: ${file.url}`) } + files.set(file.url, rewritten) + } + first ??= leg + } + const merged = { ...first, files: [...files.values()] } + if (merged.path) { merged.path = `/releases/tag/${tag}/${merged.path}` } + const text = yaml.dump(merged, { lineWidth: -1, noRefs: true }) + macFeedReferences(text) + return { key: `${selection.directory}/${selection.fileName}`, text, files: merged.files, version } +} + +/** The transport verifies immutable bytes and conditionally replaces one pointer. */ +export async function publishMacFeed(plan, transport) { + const live = await transport.read(plan.key) + if (live) { + const oldFeed = parseMacFeed(live.text) + const nextFeed = parseMacFeed(plan.text) + const order = semver.compare(plan.version, oldFeed.version) + if (order < 0) { throw new Error('Refusing to move the macOS feed backward') } + if (order === 0) { + if (!isDeepStrictEqual(oldFeed, nextFeed)) { throw new Error('Refusing to replace published version with different artifacts') } + return + } + } + for (const file of plan.files) { await transport.verify(file.url.slice(1), file) } + await transport.write(plan.key, plan.text, live?.etag ?? null) + const published = await transport.read(plan.key) + if (!published || published.text !== plan.text) { throw new Error('macOS feed readback differs from publication') } +} diff --git a/scripts/msix-shared.mjs b/scripts/msix-shared.mjs index 606876709c..823f47faac 100644 --- a/scripts/msix-shared.mjs +++ b/scripts/msix-shared.mjs @@ -48,6 +48,8 @@ const CONTENT_TYPES = { * filename suffix. Extensionless keys (inrelease/release/packages — the apt * repo metadata) match by exact basename only, so 'foo-release' or * 'xrelease' never collide with the apt 'Release' file. + * @param {string} filename + * @returns {string | undefined} */ export function contentTypeFor(filename) { const lower = String(filename).toLowerCase() diff --git a/scripts/r2-release.mjs b/scripts/r2-release.mjs index 9be53a19e8..ae3568f71d 100644 --- a/scripts/r2-release.mjs +++ b/scripts/r2-release.mjs @@ -25,8 +25,7 @@ // releases/darwin//*.{dmg,zip,blockmap} // where is stable | canary (from the tag: -canary. → canary). // The publish-win32-updater job merges the win32 legs' staging into the -// win32 feed; the darwin feed merge (r2 finalize) is currently DISABLED -// (no macOS updater arm). +// win32 feed; r2 finalize publishes the validated Darwin channel feed. import { createHash, createHmac } from 'node:crypto' import fs from 'node:fs' @@ -239,121 +238,20 @@ export function feedDirFor(platform, channel) { return `releases/${platform}/${channel}` } -/** - * Merge per-leg electron-updater feed ymls (same channel + platform) into one. - * Each leg's yml (mac: latest-mac.yml / canary-mac.yml) lists only its own - * arch's files[]; the merged yml keeps the top-level fields of the first leg - * (version/releaseDate) and the trailing top-level path/sha512, with the - * files[] entries concatenated and deduped by url. Idempotent: merging an - * already-merged yml is a no-op. - * - * Structure of a feed yml (electron-builder): - * version: ... - * files: - * - url: ... ← entries (indented list items) - * sha512: ... - * size: ... - * path: ... ← trailing top-level fields - * sha512: ... - * releaseDate: ... - */ -export function mergeFeedYmls(ymls) { - if (ymls.length === 0) return '' - const seen = new Set() - const entries = [] - - for (const yml of ymls) { - // Split at the files: marker. Entries are the indented list items - // (lines starting with whitespace + '- url:'); the tail is everything - // after the last entry (top-level path/sha512/releaseDate). - const filesIdx = yml.indexOf('\nfiles:') - if (filesIdx === -1) continue - const body = yml.slice(filesIdx + 1) // starts right after '\nfiles:' - const lines = body.split('\n') - let i = 0 - // Skip the 'files:' line itself. - if (lines[0].trim() === '') i = 1 - // Collect entry blocks: lines starting with '- url:' plus their - // following indented sha512/size lines. - while (i < lines.length) { - const line = lines[i] - if (/^\s*-\s+url:/.test(line)) { - const block = [line] - let j = i + 1 - while (j < lines.length && /^\s+(?:sha512|size):/.test(lines[j])) { - block.push(lines[j]) - j++ - } - const urlMatch = line.match(/url:\s*([^\s]+)/) - if (urlMatch && !seen.has(urlMatch[1])) { - seen.add(urlMatch[1]) - entries.push(block.join('\n')) - } - i = j - } else { - i++ - } - } - } - - const first = ymls[0] - const firstFilesIdx = first.indexOf('\nfiles:') - const head = firstFilesIdx === -1 ? first : first.slice(0, firstFilesIdx) - - // Tail: everything after the LAST entry block in the FIRST yml (the - // top-level path/sha512/releaseDate lines). - let tail = '' - { - const body = first.slice(firstFilesIdx + 1) - const lines = body.split('\n') - let lastEntryEnd = -1 - for (let i = 0; i < lines.length; i++) { - if (/^\s*-\s+url:/.test(lines[i])) { - let j = i + 1 - while (j < lines.length && /^\s+(?:sha512|size):/.test(lines[j])) j++ - lastEntryEnd = j - i = j - 1 - } - } - if (lastEntryEnd !== -1) { - tail = lines.slice(lastEntryEnd).join('\n').replace(/^\n+/, '') - } - } - - const body = ['files:', ...entries].join('\n') - const parts = [head, body] - if (tail.trim() !== '') parts.push(tail) - return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n' -} - -/** - * Rewrite a feed yml's path:/url: entries to ABSOLUTE /releases/tag// - * object keys. The feed manifest lives in releases/darwin// but the - * binaries live once in the tag archive; both updater mechanisms resolve the - * value against the feed host root (electron-updater: new URL(path, baseUrl)). - * `absKey` maps a filename to its absolute key (e.g. /releases/tag/v0.28.0/x). - * Values that already start with '/' are left alone (idempotent). - */ -export function rewriteFeedPaths(ymlText, absKey) { - return ymlText.replace(/^(\s*(?:-\s+)?(?:path|url)):\s*([^\s#]+)\s*$/gm, (_m, key, value) => { - if (value.startsWith('/')) return _m - return `${key}: ${absKey(value)}` - }) -} - // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- /** APT indexes are mutable; by-hash indexes and versioned packages are not. */ export function cacheControlFor(key) { + if (key.startsWith('releases/darwin/') && key.endsWith('-mac.yml')) return 'no-store' if (!key.startsWith('releases/termux/')) return undefined return key.includes('/by-hash/') || key.includes('/pool/') ? 'public, max-age=31536000, immutable' : 'no-store' } -async function putObject(creds, base, bucket, key, payload, now, contentType) { +async function putObject(creds, base, bucket, key, payload, now, contentType, conditions = {}) { // `payload` is either a small in-memory Buffer (feed manifests from // finalize) or a FILE PATH (binaries via `put`). The msixbundle is // ~2.7GB so path payloads stream from disk (fs.readFileSync throws @@ -376,8 +274,12 @@ async function putObject(creds, base, bucket, key, payload, now, contentType) { // should not be disturbed"). const body = isPath ? fs.createReadStream(payload) : payload const cacheControl = cacheControlFor(key) - const extraHeaders = cacheControl ? { 'Cache-Control': cacheControl } : undefined + const extraHeaders = { ...conditions, ...(cacheControl ? { 'Cache-Control': cacheControl } : {}) } const { res, text } = await signedFetch('PUT', url, { body, bodyHash, contentLength: size, creds, now, contentType, extraHeaders }) + if (res.status === 412 && isPath && conditions['If-None-Match'] === '*') { + await verifyRemoteArtifact(url, creds, now, size, bodyHash, 'sha256', 'hex') + return + } if (!res.ok) throw new Error(`PUT ${key} -> ${res.status}${text ? `: ${text.slice(0, 300)}` : ''}`) }) // HEAD can come back without content-length (intermediaries strip it on @@ -411,7 +313,7 @@ async function putObject(creds, base, bucket, key, payload, now, contentType) { console.log(`✓ r2: ${key} (${size} bytes)`) } -async function cmdPut({ tag, key, file, keyIsFull = false }) { +export async function cmdPut({ tag, key, file, keyIsFull = false }) { const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID') const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID') const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY') @@ -421,54 +323,53 @@ async function cmdPut({ tag, key, file, keyIsFull = false }) { const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') const keyPath = keyIsFull ? key : stagingKeyFor(tag, key) - await putObject(creds, base, bucket, keyPath, file, now, contentTypeFor(key)) + const immutableMac = keyPath.startsWith('releases/tag/') && /-mac-(arm64|x64)\.(zip|dmg)(\.blockmap)?$/.test(keyPath) + await putObject(creds, base, bucket, keyPath, file, now, contentTypeFor(key), immutableMac ? { 'If-None-Match': '*' } : {}) } -/** - * finalize: write the per-channel feed MANIFESTS that point at the staged - * binaries. Binaries live ONCE under releases/tag// (staged by the - * matrix legs); the feed dirs carry only the manifests: - * - * releases/darwin//-mac.yml - * merged electron-updater feed; path:/url: entries rewritten to the - * absolute /releases/tag// locations (electron-updater - * resolves them with new URL(path, baseUrl)). - * - * The win32 App Installer feed (.appinstaller + .msixbundle per channel - * dir) is produced by the msixbundle job (scripts/stage-msixbundle.mjs), - * which uploads the manifests directly — nothing for finalize to merge. - * - * Expects --dir to contain the merged METADATA for ONE tag (the build - * matrix uploads only this — the binaries go straight to R2 from each - * leg and are never round-tripped through artifacts): - * *-mac.yml the per-leg electron-updater feed files - */ -async function cmdFinalize({ tag, dir }) { +async function verifyRemoteArtifact(urlValue, creds, now, expectedSize, digest, algorithm = 'sha512', encoding = 'base64') { + const url = new URL(urlValue) + const headers = r2Headers('GET', url.host, url.pathname, '', EMPTY_SHA, now, creds) + const response = await fetch(url, { headers, signal: AbortSignal.timeout(600_000) }) + if (!response.ok || !response.body) throw new Error(`Cannot verify ${url.pathname}: ${response.status}`) + const hash = createHash(algorithm) + let size = 0 + for await (const chunk of response.body) { hash.update(chunk); size += chunk.length } + if (size !== expectedSize || hash.digest(encoding) !== digest) throw new Error(`Artifact checksum mismatch: ${url.pathname}`) +} + +/** Validate both native legs, verify their bytes, then replace the feed pointer. */ +export async function cmdFinalize({ tag, dir, variant }) { + const { mergeMacFeeds, publishMacFeed } = await import('./darwin-feed.mjs') + if (variant && variant !== 'light') throw new Error('Unknown macOS variant') const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID') - const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID') - const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY') - const bucket = requiredEnv('CLOUDFLARE_R2_BUCKET') - const creds = { accessKeyId, secretKey } - const base = s3Endpoint(accountId) - const channel = channelForTag(tag) - const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') - - const files = fs.readdirSync(dir).filter((f) => fs.statSync(path.join(dir, f)).isFile()) - // Absolute key of a staged artifact — the feed manifests reference these. - const absKey = (filename) => `/${stagingKeyFor(tag, filename)}` - - // --- darwin: merged feed yml only (dmg/zip/blockmap stay in the tag dir) --- - const macYmls = files.filter((f) => f.endsWith(`-mac.yml`)) - if (macYmls.length > 0) { - const darDir = feedDirFor('darwin', channel) - const macFeedName = `${channel}-mac.yml` - // Rewrite path:/url: to absolute /releases/tag// locations so the - // client fetches binaries from the archive, not the feed dir. - const merged = rewriteFeedPaths(mergeFeedYmls(macYmls.map((f) => fs.readFileSync(path.join(dir, f), 'utf8'))), absKey) - await putObject(creds, base, bucket, `${darDir}/${macFeedName}`, Buffer.from(merged, 'utf8'), now) + const creds = { + accessKeyId: requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID'), + secretKey: requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY') } - - console.log(`✓ r2: finalized ${tag} → ${channel} feed manifests`) + const bucket = requiredEnv('CLOUDFLARE_R2_BUCKET') + const base = s3Endpoint(accountId) + const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') + const legs = Object.fromEntries(fs.readdirSync(dir).filter(name => name.endsWith('-mac.yml')) + .map(name => [name, fs.readFileSync(path.join(dir, name), 'utf8')])) + const plan = mergeMacFeeds(legs, tag, variant === 'light') + await publishMacFeed(plan, { + read: async key => { + const url = `${base}/${bucket}/${encodeKeyPath(key)}` + const { res, text } = await signedFetch('GET', url, { bodyHash: EMPTY_SHA, creds, now }) + if (res.status === 404) return null + if (!res.ok) throw new Error(`GET ${key} -> ${res.status}`) + const etag = res.headers.get('etag') + if (!etag) throw new Error(`No ETag for ${key}`) + return { text, etag } + }, + verify: async (key, file) => { + await verifyRemoteArtifact(`${base}/${bucket}/${encodeKeyPath(key)}`, creds, now, file.size, file.sha512) + }, + write: (key, text, etag) => putObject(creds, base, bucket, key, Buffer.from(text), now, + 'application/yaml', etag ? { 'If-Match': etag } : { 'If-None-Match': '*' }) + }) + console.log(`✓ r2: finalized ${tag} → ${plan.key}`) } /** Parse a ListObjectsV2 XML body into { keys, lastModified, truncated, nextToken }. */ @@ -624,7 +525,7 @@ export function staleFeedBundleKeys(keys, feedXmlByDir, lastModifiedMs = {}, cut return doomed } -async function cmdPrune({ keepDays, dryRun }) { +export async function cmdPrune({ keepDays, dryRun }) { const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID') const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID') const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY') @@ -654,6 +555,12 @@ async function cmdPrune({ keepDays, dryRun }) { for (const k of feedReferencedKeys(dir, xml)) protectedKeys.add(k) } + for (const key of keys.filter(key => key.startsWith('releases/darwin/') && key.endsWith('-mac.yml'))) { + const { macFeedReferences } = await import('./darwin-feed.mjs') + const text = await getObject(creds, base, bucket, key, now) + for (const reference of macFeedReferences(text)) protectedKeys.add(reference) + } + const doomed = [ ...canaryDoomedKeys(keys, cutoff), ...staleFeedBundleKeys(keys, feedXmlByDir, lastModified, cutoffMs), @@ -701,7 +608,7 @@ export async function main(argv = process.argv.slice(2)) { const args = {} for (let i = 0; i < rest.length; i++) { const flag = rest[i] - if (['--tag', '--key', '--file', '--prefix', '--keep-days', '--dir'].includes(flag)) { + if (['--tag', '--key', '--file', '--prefix', '--keep-days', '--dir', '--variant'].includes(flag)) { args[flag.slice(2)] = rest[++i] } else if (flag === '--dry-run' || flag === '--key-is-full') { args[flag.slice(2)] = true @@ -715,7 +622,7 @@ export async function main(argv = process.argv.slice(2)) { await cmdPut({ tag, key: args.key, file: args.file, keyIsFull: Boolean(args['key-is-full']) }) } else if (cmd === 'finalize') { if (!args.tag || !args.dir) usage() - await cmdFinalize({ tag: args.tag, dir: args.dir }) + await cmdFinalize({ tag: args.tag, dir: args.dir, variant: args.variant }) } else if (cmd === 'list') { await cmdList({ prefix: args.prefix ?? '' }) } else if (cmd === 'prune-canaries') { diff --git a/tests-js/darwin-feed.test.mjs b/tests-js/darwin-feed.test.mjs new file mode 100644 index 0000000000..0582d5192e --- /dev/null +++ b/tests-js/darwin-feed.test.mjs @@ -0,0 +1,148 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import yaml from 'js-yaml' +import { macFeedReferences, mergeMacFeeds, parseMacFeed, publishMacFeed } from '../scripts/darwin-feed.mjs' +import { cacheControlFor, cmdFinalize, cmdPrune, cmdPut } from '../scripts/r2-release.mjs' +import feedContract from '../apps/desktop/update-feed.cjs' + +function inputs(version = '0.28.0', light = false) { + const channel = version.includes('-canary.') ? 'canary' : 'stable' + const bytes = new Map() + const legs = Object.fromEntries(['arm64', 'x64'].map((arch, i) => { + const name = `${light ? 'HermesLight' : 'HermesBundled'}-${version}-mac-${arch}.zip` + const data = Buffer.from(`test artifact ${arch}`) + bytes.set(`releases/tag/v${version}/${name}`, data) + const file = { url: name, size: data.length, sha512: createHash('sha512').update(data).digest('base64') } + return [`${arch}-${channel}-mac.yml`, yaml.dump({ version, files: [file], path: name, sha512: file.sha512, releaseDate: `2026-09-0${i + 1}T00:00:00Z`, releaseNotes: 'two lines\nof release notes' })] + })) + return { legs, bytes } +} + +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs() }) + +function credentials() { + for (const name of ['CLOUDFLARE_R2_ACCOUNT_ID', 'CLOUDFLARE_R2_ACCESS_KEY_ID', 'CLOUDFLARE_R2_SECRET_ACCESS_KEY', 'CLOUDFLARE_R2_BUCKET']) vi.stubEnv(name, 'fixture') +} + +it('never overwrites a published macOS artifact on a same-tag rerun', async () => { + credentials() + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mac-artifact-')) + const name = 'HermesBundled-0.28.0-mac-arm64.zip' + const file = path.join(dir, name) + const bytes = Buffer.from('already published artifact') + fs.writeFileSync(file, bytes) + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + if (options.method === 'PUT') { + expect(options.headers['If-None-Match']).toBe('*') + for await (const chunk of options.body) { expect(chunk.length).toBeGreaterThan(0) } + return new Response('', { status: 412 }) + } + if (options.method === 'HEAD') return new Response(null, { headers: { 'content-length': String(bytes.length) } }) + return new Response(bytes) + })) + try { await cmdPut({ tag: 'v0.28.0', key: name, file }) } + finally { fs.rmSync(dir, { recursive: true, force: true }) } +}) + +it('the real prune command protects live macOS ZIPs and blockmaps, and fails closed', async () => { + credentials() + const plan = mergeMacFeeds(inputs('0.28.0-canary.20200101000000').legs, 'v0.28.0-canary.20200101000000') + const references = macFeedReferences(plan.text) + const stale = 'releases/tag/v0.27.0-canary.20200101000000/unreferenced.zip' + const keys = [plan.key, ...references, stale] + const listing = `${keys.map(key => `${key}`).join('')}false` + const deleted = [] + let readable = true + vi.stubGlobal('fetch', vi.fn(async (url, options) => { + if (new URL(url).search) return new Response(listing) + if (options.method === 'DELETE') { deleted.push(decodeURIComponent(new URL(url).pathname)); return new Response('') } + return readable ? new Response(plan.text) : new Response('', { status: 503 }) + })) + await cmdPrune({ keepDays: 14, dryRun: false }) + expect(deleted).toEqual([`/fixture/${stale}`]) + deleted.length = 0 + readable = false + await expect(cmdPrune({ keepDays: 14, dryRun: false })).rejects.toThrow() + expect(deleted).toEqual([]) +}) + +it('merges native legs with different signatures/dates and preserves release metadata', () => { + const { legs } = inputs() + const plan = mergeMacFeeds(legs, 'v0.28.0') + const feed = parseMacFeed(plan.text) + expect(feed.files).toHaveLength(2) + expect(feed.releaseNotes).toBe('two lines\nof release notes') + expect(plan.key).toBe('releases/darwin/stable/stable-mac.yml') + expect(macFeedReferences(plan.text)).toEqual(feed.files.flatMap(file => [file.url.slice(1), `${file.url.slice(1)}.blockmap`])) + expect(cacheControlFor(plan.key)).toBe('no-store') + const selection = feedContract.darwinFeed('canary', true) + expect(mergeMacFeeds(inputs('0.29.0-canary.20260906000000', true).legs, 'v0.29.0-canary.20260906000000', true).key) + .toBe(`${selection.directory}/${selection.fileName}`) +}) + +it.each(['missing', 'version', 'variant', 'hash', 'legacy', 'traversal'])('rejects %s instead of publishing a partial or wrong feed', kind => { + const { legs } = inputs() + const key = 'arm64-stable-mac.yml' + const feed = yaml.load(legs[key]) + if (kind === 'missing') delete legs[key] + else { + if (kind === 'version') feed.version = '0.27.0' + if (kind === 'variant') feed.files[0].url = feed.files[0].url.replace('HermesBundled', 'HermesLight') + if (kind === 'hash') feed.files[0].sha512 = 'invalid' + if (kind === 'legacy') feed.sha512 = 'wrong' + if (kind === 'traversal') feed.files[0].url = '../other.zip' + legs[key] = yaml.dump(feed) + } + expect(() => mergeMacFeeds(legs, 'v0.28.0')).toThrow() +}) + +it('verifies all artifacts before a conditional pointer write and readback', async () => { + const plan = mergeMacFeeds(inputs().legs, 'v0.28.0') + let live = { text: mergeMacFeeds(inputs('0.27.0').legs, 'v0.27.0').text, etag: 'old' } + const events = [] + await publishMacFeed(plan, { + read: async () => live, + verify: async key => { events.push(key) }, + write: async (key, text, etag) => { expect(etag).toBe('old'); events.push(key); live = { text, etag: 'new' } } + }) + expect(events.at(-1)).toBe(plan.key) + expect(events).toHaveLength(3) + const write = vi.fn() + await expect(publishMacFeed(mergeMacFeeds(inputs('0.27.0').legs, 'v0.27.0'), { read: async () => live, verify: vi.fn(), write })).rejects.toThrow('backward') + await expect(publishMacFeed(plan, { read: async () => null, verify: async () => { throw new Error('corrupt bytes') }, write })).rejects.toThrow('corrupt bytes') + expect(write).not.toHaveBeenCalled() +}) + +it('finalize uses the real signed transport, validates streamed hashes and publishes last', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mac-feed-')) + const { legs, bytes } = inputs() + for (const [name, text] of Object.entries(legs)) fs.writeFileSync(path.join(dir, name), text) + for (const [key, value] of Object.entries({ CLOUDFLARE_R2_ACCOUNT_ID: 'fixture', CLOUDFLARE_R2_ACCESS_KEY_ID: 'fixture', CLOUDFLARE_R2_SECRET_ACCESS_KEY: 'fixture', CLOUDFLARE_R2_BUCKET: 'bucket' })) vi.stubEnv(key, value) + const calls = [] + let published + vi.stubGlobal('fetch', vi.fn(async (url, options) => { + const key = decodeURIComponent(new URL(url).pathname).replace('/bucket/', '') + const method = options.method || 'GET' + calls.push([method, key]) + expect(options.headers.authorization).toContain('AWS4-HMAC-SHA256') + if (method === 'PUT') { + expect(options.headers['If-None-Match']).toBe('*') + expect(options.headers['Cache-Control']).toBe('no-store') + published = options.body.toString() + return new Response('', { status: 200 }) + } + if (method === 'HEAD') return new Response(null, { headers: { 'content-length': Buffer.byteLength(published).toString() } }) + if (key.endsWith('-mac.yml')) return published ? new Response(published, { headers: { etag: 'new' } }) : new Response('', { status: 404 }) + if (bytes.has(key)) return new Response(bytes.get(key)) + throw new Error(`unexpected request ${key}`) + })) + try { + await cmdFinalize({ tag: 'v0.28.0', dir }) + expect(parseMacFeed(published).files).toHaveLength(2) + expect(calls.filter(([method]) => method === 'PUT')).toHaveLength(1) + expect(calls.slice(0, 3).every(([method]) => method === 'GET')).toBe(true) + } finally { fs.rmSync(dir, { recursive: true, force: true }) } +}) diff --git a/tests-js/macos-publish-config.test.mjs b/tests-js/macos-publish-config.test.mjs new file mode 100644 index 0000000000..b546377e14 --- /dev/null +++ b/tests-js/macos-publish-config.test.mjs @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import feedContract from '../apps/desktop/update-feed.cjs' + +const root = fileURLToPath(new URL('../', import.meta.url)) + +it.each([ + ['bundled', 'v0.28.0', 'stable', false], + ['bundled', 'v0.29.0-canary.20260906000000', 'canary', false], + ['light', 'v0.28.0', 'stable', true], + ['light', 'v0.29.0-canary.20260906000000', 'canary', true] +])('packaging and runtime agree for %s %s', (variant, tag, channel, light) => { + const result = execFileSync(process.execPath, ['-e', "const c=require('./apps/desktop/electron-builder.config.cjs'); console.log(JSON.stringify({publish:c.mac.publish,notarize:c.mac.notarize,targets:c.mac.target}))"], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, HERMES_DESKTOP_VARIANT: variant, HERMES_PAYLOAD_TAG: tag, CLOUDFLARE_R2_PUBLIC_URL: 'https://updates.example' } + }) + const config = JSON.parse(result) + const feed = feedContract.darwinFeed(channel, light) + expect(config.publish).toEqual([{ provider: 'generic', url: `https://updates.example/${feed.directory}/`, channel: feed.channel }]) + expect(config.targets).toContain('zip') + expect(config.notarize).toBe(false) +}) diff --git a/tests-js/r2-release.test.mjs b/tests-js/r2-release.test.mjs index 9e72762a88..3b1d49cc1a 100644 --- a/tests-js/r2-release.test.mjs +++ b/tests-js/r2-release.test.mjs @@ -28,12 +28,10 @@ import { encodeKeyPath, feedDirFor, feedReferencedKeys, - mergeFeedYmls, canaryDoomedKeys, publishFeedUploads, referencedFeedBundleFilenames, staleFeedBundleKeys, - rewriteFeedPaths, stagingKeyFor, parseListXml, rfc3986Encode, @@ -231,63 +229,6 @@ test('canonicalRequest reads mixed-case header values (Content-Type)', () => { assert.ok(canon.includes('content-type;host;x-amz-content-sha256;x-amz-date')) }) -test('rewriteFeedPaths rewrites path:/url: to absolute /releases/tag keys, idempotent', () => { - const absKey = (f) => `/releases/tag/v0.28.0/${f}` - const yml = `version: 0.28.0 -files: - - url: HermesBundled-0.28.0-mac-x64.zip - sha512: abc - size: 1 - - url: HermesBundled-0.28.0-mac-x64.dmg - sha512: def - size: 2 -path: HermesBundled-0.28.0-mac-x64.zip -sha512: ghi -releaseDate: '2026-08-18T00:00:00.000Z' -` - const once = rewriteFeedPaths(yml, absKey) - assert.ok(once.includes('url: /releases/tag/v0.28.0/HermesBundled-0.28.0-mac-x64.zip')) - assert.ok(once.includes('url: /releases/tag/v0.28.0/HermesBundled-0.28.0-mac-x64.dmg')) - assert.ok(once.includes('path: /releases/tag/v0.28.0/HermesBundled-0.28.0-mac-x64.zip')) - assert.ok(once.includes('sha512: abc')) // artifact hashes untouched - // Already-absolute values are left alone (a re-finalize must not double-prefix). - assert.equal(rewriteFeedPaths(once, absKey), once) -}) - -test('mergeFeedYmls concatenates files[] lists, dedupes, keeps head', () => { - const x64 = `version: 0.28.0 -files: - - url: HermesBundled-0.28.0-mac-x64.zip - sha512: abc - size: 1 - - url: HermesBundled-0.28.0-mac-x64.dmg - sha512: def - size: 2 -path: HermesBundled-0.28.0-mac-x64.zip -sha512: ghi -releaseDate: '2026-08-18T00:00:00.000Z' -` - const arm64 = `version: 0.28.0 -files: - - url: HermesBundled-0.28.0-mac-arm64.zip - sha512: jkl - size: 3 - - url: HermesBundled-0.28.0-mac-arm64.dmg - sha512: mno - size: 4 -path: HermesBundled-0.28.0-mac-arm64.zip -sha512: pqr -releaseDate: '2026-08-18T00:00:00.000Z' -` - const merged = mergeFeedYmls([x64, arm64]) - assert.ok(merged.includes('url: HermesBundled-0.28.0-mac-x64.zip')) - assert.ok(merged.includes('url: HermesBundled-0.28.0-mac-arm64.zip')) - assert.ok(merged.includes('releaseDate')) - // Idempotent: merging the merged output adds nothing new. - assert.equal(mergeFeedYmls([merged, arm64]), merged) -}) - - // ── C22: artifact first, feed pointer last ────────────────────────────────── test('publishFeedUploads uploads the msixbundle BEFORE the .appinstaller pointer', () => {