diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 823bf0d4ced..7420b414215 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -172,7 +172,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be ## Auto-update, channels, rollout, rollback -- `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Sim validates every candidate before starting its download. Developer ID builds installed under `/Applications` use a prompt (Restart and update / Later; Later installs on quit); other packaged builds offer a validated installer download — never forced mid-session. +- `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Sim validates every candidate before starting its download. Developer ID builds installed under `/Applications` use a prompt (Restart and update / Later; Later installs on quit); other packaged builds offer a validated installer download — never forced mid-session. A staged or offered update keeps being re-checked on the normal cadence, and a newer release replaces it, so a shell left running across several releases installs the latest build in one restart instead of the stale one followed by another prompt. - Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration. - Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean. - Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.) diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 7f49db4fb9a..59ed1d06ab4 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -24,7 +24,7 @@ const autoUpdaterMock = { quitAndInstall: vi.fn(), } -import { app, dialog, shell } from 'electron' +import { app, dialog, shell, autoUpdater as squirrelUpdater } from 'electron' import { checkForUpdatesInteractive, feedUrlForOrigin, @@ -153,6 +153,24 @@ describe('initUpdater state machine', () => { } } + /** Replays a native Squirrel.Mac event, e.g. `update-downloaded` once a bundle is staged. */ + function emitSquirrel(event: string) { + for (const [name, listener] of vi.mocked(squirrelUpdater.on).mock.calls) { + if (name === event) { + ;(listener as () => void)() + } + } + } + + /** Drives a fresh updater to a Squirrel-staged `ready` update for `version`. */ + async function stageUpdate(handle: UpdaterHandle, version: string) { + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version }) + emit('update-downloaded', { version }) + emitSquirrel('update-downloaded') + } + async function createUpdater(options?: { autoDownload?: boolean feedAvailable?: boolean | 'no-release' @@ -183,6 +201,7 @@ describe('initUpdater state machine', () => { beforeEach(() => { vi.useFakeTimers() autoUpdaterMock.on.mockClear() + vi.mocked(squirrelUpdater.on).mockClear() autoUpdaterMock.setFeedURL.mockClear() autoUpdaterMock.checkForUpdates.mockClear() autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {})) @@ -445,6 +464,228 @@ describe('initUpdater state machine', () => { }) }) + it('replaces a staged update with a newer release instead of installing the stale build', async () => { + const { handle, states } = await createUpdater() + await stageUpdate(handle, '2.0.0') + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + states.length = 0 + + await vi.advanceTimersByTimeAsync(10_000) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + emit('checking-for-update') + emit('update-available', { version: '2.1.0' }) + emit('download-progress', { percent: 50 }) + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + + emit('update-downloaded', { version: '2.1.0' }) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + + emitSquirrel('update-downloaded') + expect(states).toEqual([{ status: 'ready', version: '2.1.0' }]) + expect(events.record).toHaveBeenCalledWith('update_downloaded', { version: '2.1.0' }) + }) + + it('does not re-check a ready update until Squirrel has staged it', async () => { + const { handle } = await createUpdater() + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + emit('update-downloaded', { version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(10_000) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + emitSquirrel('update-downloaded') + await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + + it('keeps a staged update when a background re-check finds nothing newer or fails', async () => { + const { handle, states } = await createUpdater() + await stageUpdate(handle, '2.0.0') + states.length = 0 + + await vi.advanceTimersByTimeAsync(10_000) + emit('update-available', { version: '2.0.0' }) + await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000) + emit('update-not-available') + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + emit('error', new Error('net::ERR_NETWORK_CHANGED')) + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + emit('update-available', { version: '2.1.0' }) + emit('error', new Error('download interrupted')) + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + emit('update-available', { version: '2.2.0' }) + emit('update-downloaded', { version: '2.2.0' }) + emit('error', new Error('Squirrel could not verify the replacement')) + + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(6) + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(3) + expect(states).toEqual([]) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + + emitSquirrel('update-downloaded') + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + }) + + it('does not replace a staged update when background downloads are disabled', async () => { + const { handle } = await createUpdater() + await stageUpdate(handle, '2.0.0') + handle.setAutoDownload(false) + + await vi.advanceTimersByTimeAsync(10_000) + + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + }) + + it('completes a confirmed restart when a background re-check fails during teardown', async () => { + let finishTeardown: (() => void) | undefined + const setRelaunchPending = vi.fn() + const { handle } = await createUpdater({ + beforeInstall: () => + new Promise((resolve) => { + finishTeardown = resolve + }), + setRelaunchPending, + }) + await stageUpdate(handle, '2.0.0') + await vi.advanceTimersByTimeAsync(10_000) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ + response: 1, + checkboxChecked: false, + }) + handle.install() + await vi.advanceTimersByTimeAsync(0) + emit('error', new Error('net::ERR_NETWORK_CHANGED')) + finishTeardown?.() + await vi.advanceTimersByTimeAsync(0) + + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + expect(setRelaunchPending).toHaveBeenCalledWith(true) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('surfaces a relaunch failure after a confirmed restart', async () => { + const setRelaunchPending = vi.fn() + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ + response: 1, + checkboxChecked: false, + }) + const { handle } = await createUpdater({ beforeInstall: async () => {}, setRelaunchPending }) + await stageUpdate(handle, '2.0.0') + handle.install() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + + emit('error', new Error('ShipIt could not launch')) + + expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' }) + expect(setRelaunchPending).toHaveBeenLastCalledWith(false) + }) + + it('installs a replacement that finished staging while the restart prompt was open', async () => { + let resolveConfirmation: (result: { response: number; checkboxChecked: boolean }) => void = + () => { + throw new Error('Restart confirmation did not initialize') + } + const { handle } = await createUpdater() + await stageUpdate(handle, '2.0.0') + await vi.advanceTimersByTimeAsync(10_000) + emit('update-available', { version: '2.1.0' }) + + vi.mocked(dialog.showMessageBox).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveConfirmation = resolve + }) + ) + handle.install() + emit('update-downloaded', { version: '2.1.0' }) + emitSquirrel('update-downloaded') + expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' }) + resolveConfirmation({ response: 1, checkboxChecked: false }) + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('refreshes an offered update to a newer release before it is downloaded', async () => { + const { handle } = await createUpdater({ autoDownload: false }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(10_000) + emit('checking-for-update') + emit('error', new Error('net::ERR_INTERNET_DISCONNECTED')) + expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000) + emit('update-not-available') + expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + emit('update-available', { version: '2.1.0' }) + expect(handle.getState()).toEqual({ status: 'available', version: '2.1.0' }) + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + }) + + it('resumes refreshing after a replacement download is cancelled without an error event', async () => { + const { handle } = await createUpdater() + await stageUpdate(handle, '2.0.0') + await vi.advanceTimersByTimeAsync(10_000) + autoUpdaterMock.downloadUpdate.mockImplementationOnce(() => + Promise.reject(new Error('cancelled')) + ) + emit('update-available', { version: '2.1.0' }) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3) + emit('update-available', { version: '2.1.0' }) + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(3) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + }) + + it('follows the feed when a re-check rolls an offered release back', async () => { + const { handle } = await createUpdater({ autoDownload: false }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.2.0' }) + + await vi.advanceTimersByTimeAsync(10_000) + emit('update-available', { version: '2.1.0' }) + expect(handle.getState()).toEqual({ status: 'available', version: '2.1.0' }) + + handle.check() + emit('update-downloaded', { version: '2.1.0' }) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' }) + }) + + it('withdraws an offered update once a re-check stores a blocked candidate', async () => { + const { handle } = await createUpdater({ autoDownload: false }) + handle.check() + await vi.advanceTimersByTimeAsync(0) + emit('update-available', { version: '2.0.0' }) + + await vi.advanceTimersByTimeAsync(10_000) + emit('update-available', { version: '2.1.0-dev.1' }) + + expect(handle.getState()).toEqual({ status: 'idle' }) + handle.check() + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + }) + it('checks from idle and ignores re-entrant checks while busy', async () => { const { handle } = await createUpdater() handle.check() @@ -1035,6 +1276,31 @@ describe('initUpdater manual mode (no Developer ID signature)', () => { expect(handle.getState()).toEqual({ status: 'error', manual: true }) }) + it('replaces an offered manual download with a newer release', async () => { + let feedVersion: string | null = '2.0.0' + const fetchManifest = vi.fn(async () => { + if (feedVersion === null) throw new Error('network down') + return manifest(feedVersion) + }) + const { handle } = await createManualUpdater(fetchManifest) + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0', manual: true }) + + feedVersion = null + await vi.advanceTimersByTimeAsync(10_000) + expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0', manual: true }) + + feedVersion = '2.1.0' + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + expect(handle.getState()).toEqual({ status: 'available', version: '2.1.0', manual: true }) + + handle.install() + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/simstudioai/sim/releases/download/v2.1.0/Sim-2.1.0-universal.dmg' + ) + }) + it('checks on the scheduled interval', async () => { const fetchManifest = vi.fn(async () => manifest('9.9.9')) await createManualUpdater(fetchManifest) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 1f111282976..3dc129e3ce4 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -3,7 +3,7 @@ import type { DesktopUpdateState } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { BrowserWindow } from 'electron' -import { app, net } from 'electron' +import { app, net, autoUpdater as squirrelUpdater } from 'electron' import { showShellDialog } from '@/main/dialogs' import { isSafeExternalUrl, openExternalSafe } from '@/main/navigation' import type { EventRecorder } from '@/main/observability' @@ -394,19 +394,32 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { autoUpdater.logger = null let installInFlight = false let installConfirmationInFlight = false + /** + * True once Squirrel.Mac holds a verified bundle it will install on exit. + * It keeps that bundle through any later failed check, download, or + * restage, so only a failure before staging or during relaunch leaves + * nothing installable. + */ + let squirrelStaged = false + let relaunchRequested = false - const quitAndInstall = (version: string | undefined) => { + /** + * Squirrel installs whatever it has staged when the process exits, so a + * newer build that replaced the staged one mid-confirmation still installs. + */ + const quitAndInstall = () => { if (installInFlight) return installInFlight = true void Promise.resolve() .then(() => deps.beforeInstall?.()) .then(() => { - if (state.status !== 'ready' || state.version !== version) { + if (state.status !== 'ready') { autoUpdater.autoInstallOnAppQuit = false installInFlight = false return } deps.setRelaunchPending?.(true) + relaunchRequested = true autoUpdater.quitAndInstall() }) .catch((error) => { @@ -438,8 +451,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const confirmation = win ? showShellDialog(win, options) : showShellDialog(options) void confirmation .then(({ response }) => { - if (response === 1 && state.status === 'ready' && state.version === version) { - quitAndInstall(version) + if (response === 1 && state.status === 'ready') { + quitAndInstall() } }) .catch((error) => { @@ -459,7 +472,29 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { let nextUpdaterCheckId = 0 let updaterCheckTimeout: ReturnType | null = null let updaterRequestId: number | null = null + /** + * The validated version whose download is in flight. While `ready`, a + * non-null value means a newer build is replacing the staged one. + */ let acceptedUpdateVersion: string | null = null + /** A downloaded replacement that becomes `ready` once Squirrel stages it. */ + let pendingReplacementVersion: string | null = null + + /** + * A staged (`ready`) or offered (`available`) update keeps being re-checked + * in the background so a newer release replaces it. Without this, a shell + * left running across several releases installs the stale build on + * restart and immediately offers the next one. + */ + const isRefreshingOffer = () => state.status === 'ready' || state.status === 'available' + + const canRefreshStagedUpdate = () => + squirrelStaged && + autoDownloadEnabled && + !installInFlight && + !installConfirmationInFlight && + acceptedUpdateVersion === null && + pendingReplacementVersion === null const finishProbe = (probeId: number) => { if (activeProbeId !== probeId) return @@ -479,7 +514,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } autoUpdater.on('checking-for-update', () => { - if (activeUpdaterCheckId === null) return + if (activeUpdaterCheckId === null || isRefreshingOffer()) return setState({ status: 'checking' }) }) @@ -488,6 +523,9 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { if (checkId === null) return finishUpdaterCheck(checkId) if (updaterRequestId === checkId) updaterRequestId = null + // The library keeps the last validated offer when nothing newer exists, + // and a staged bundle is already armed in Squirrel. + if (isRefreshingOffer()) return setState({ status: 'idle' }) }) @@ -501,7 +539,42 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { !originFeedConfigured || (info.files.length > 0 && info.files.every((file) => isReleaseAssetUrl(file.url, info.version, channel))) - if (!isValidUpdateCandidate(info.version, currentVersion) || !validOriginAssets) { + const validCandidate = + validOriginAssets && isValidUpdateCandidate(info.version, currentVersion) + if (state.status === 'ready') { + // Only a strictly newer validated release replaces the staged one; the + // download reuses the update info this check just stored. + const stagedVersion = state.version ?? currentVersion + if ( + !validCandidate || + !autoDownloadEnabled || + !isNewerVersion(info.version, stagedVersion) + ) { + return + } + acceptedUpdateVersion = info.version + deps.events.record('update_check', { available: info.version, replacing: stagedVersion }) + const replacementVersion = info.version + // Cancellation rejects without an `error` event, so the promise owns + // clearing its replacement for every failure mode. + void autoUpdater.downloadUpdate().catch((error) => { + if (acceptedUpdateVersion === replacementVersion) acceptedUpdateVersion = null + if (pendingReplacementVersion === replacementVersion) pendingReplacementVersion = null + logger.warn('Replacement update download failed; keeping the staged update', { + message: getErrorMessage(error, 'unknown'), + }) + }) + return + } + // An offer mirrors the feed's latest release, even after a rollback: the + // library only keeps this check's update info, so Update would download + // this version regardless of which one the offer displayed. + if (state.status === 'available' && validCandidate && state.version === info.version) { + return + } + // A blocked candidate also replaces the library's pending update info, so + // an `available` offer from an earlier check is no longer safe to download. + if (!validCandidate) { acceptedUpdateVersion = null autoUpdater.autoInstallOnAppQuit = false deps.events.record('update_blocked_version', { @@ -536,6 +609,14 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) autoUpdater.on('update-downloaded', (info) => { + if (state.status === 'ready') { + if (acceptedUpdateVersion !== info.version) return + acceptedUpdateVersion = null + // electron-updater hands the file to Squirrel after this event; the + // staged update switches over when Squirrel reports it staged. + pendingReplacementVersion = info.version + return + } if (state.status !== 'downloading') return if ( acceptedUpdateVersion !== info.version || @@ -553,18 +634,45 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { setState({ status: 'ready', version: info.version }) }) + squirrelUpdater.on('update-downloaded', () => { + squirrelStaged = true + const version = pendingReplacementVersion + if (version === null || state.status !== 'ready') return + pendingReplacementVersion = null + deps.events.record('update_downloaded', { version }) + setState({ status: 'ready', version }) + }) + autoUpdater.on('error', (error) => { const checkId = activeUpdaterCheckId if (checkId !== null) { finishUpdaterCheck(checkId) if (updaterRequestId === checkId) updaterRequestId = null - } else if (state.status !== 'downloading' && state.status !== 'ready' && !installInFlight) { + } + const message = getErrorMessage(error, 'unknown') + if (state.status === 'ready' && squirrelStaged && !relaunchRequested) { + acceptedUpdateVersion = null + pendingReplacementVersion = null + logger.warn('Update refresh failed; keeping the staged update', { message }) + return + } + if (state.status === 'available') { + logger.warn('Update re-check failed; keeping the offered update', { message }) + return + } + if ( + checkId === null && + state.status !== 'downloading' && + state.status !== 'ready' && + !installInFlight + ) { return } installInFlight = false + relaunchRequested = false deps.setRelaunchPending?.(false) autoUpdater.autoInstallOnAppQuit = false - deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') }) + deps.events.record('update_error', { message }) setState({ status: 'error', version: state.version }) }) @@ -660,12 +768,14 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { if ( activeProbeId !== null || activeUpdaterCheckId !== null || - state.status === 'available' || - state.status === 'downloading' || - state.status === 'ready' + state.status === 'downloading' ) { return } + if (isRefreshingOffer()) { + if (interactive) return + if (state.status === 'ready' && !canRefreshStagedUpdate()) return + } if (interactive) { setState({ status: 'checking' }) } @@ -726,18 +836,27 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { let nextCheckId = 0 let checkTimeout: ReturnType | null = null + /** + * An offered download keeps being re-checked in the background, and only a + * strictly newer release with a usable asset replaces it; failures and + * equal versions leave the current offer untouched. + */ const doCheck = async () => { - if (activeCheckId !== null || state.status === 'available') return + if (activeCheckId !== null) return + const offeredVersion = state.status === 'available' ? state.version : undefined + const refreshing = offeredVersion !== undefined const checkId = ++nextCheckId activeCheckId = checkId - downloadUrl = null - setState({ status: 'checking', manual: true }) + if (!refreshing) { + downloadUrl = null + setState({ status: 'checking', manual: true }) + } checkTimeout = setTimeout(() => { if (activeCheckId !== checkId) return activeCheckId = null checkTimeout = null deps.events.record('update_error', { message: 'Manual update check timed out' }) - setState({ status: 'error', manual: true }) + if (!refreshing) setState({ status: 'error', manual: true }) }, UPDATE_CHECK_TIMEOUT_MS) try { const feedUrl = feedUrlForOrigin(deps.appOrigin()) @@ -745,9 +864,10 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { if (activeCheckId !== checkId) return const version = manifest ? (/^version:\s*(\S+)\s*$/m.exec(manifest)?.[1] ?? null) : null if (!manifest || !version || !isValidUpdateCandidate(version, currentVersion)) { - setState({ status: 'idle', manual: true }) + if (!refreshing) setState({ status: 'idle', manual: true }) return } + if (refreshing && !isNewerVersion(version, offeredVersion)) return // The feed rewrites manifest urls to absolute GitHub asset URLs; // prefer the dmg for a human download. // @@ -759,12 +879,12 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { manifest.matchAll(/^\s*(?:-\s*)?url:\s*(\S+)\s*$/gm), (m) => m[1] ).filter((url) => isReleaseAssetUrl(url, version, resolveUpdateChannel(currentVersion))) - downloadUrl = + const nextDownloadUrl = urls.find((url) => url.endsWith('.dmg')) ?? urls.find((url) => url.endsWith('.zip')) ?? urls[0] ?? null - if (!downloadUrl) { + if (!nextDownloadUrl) { // 'error', not 'idle': a newer version demonstrably exists and cannot // be offered, so "Sim is up to date" would strand a user whose shell // the server's minimum-version gate is already blocking. @@ -773,15 +893,16 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { candidates: urls.length, }) deps.events.record('update_blocked_version', { version, reason: 'unusable-url' }) - setState({ status: 'error', version: state.version, manual: true }) + if (!refreshing) setState({ status: 'error', version: state.version, manual: true }) return } + downloadUrl = nextDownloadUrl deps.events.record('update_check', { available: version, manual: true }) setState({ status: 'available', version, manual: true }) } catch (error) { if (activeCheckId !== checkId) return logger.warn('Manual update check failed', { message: getErrorMessage(error, 'unknown') }) - setState({ status: 'error', version: state.version, manual: true }) + if (!refreshing) setState({ status: 'error', version: state.version, manual: true }) } finally { if (activeCheckId === checkId) { activeCheckId = null diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index bd5dbc9918f..b83631963a6 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -35,6 +35,11 @@ export const app = { dock: { downloadFinished: vi.fn() }, } +/** Squirrel.Mac's native updater; tests replay its events through `on` calls. */ +export const autoUpdater = { + on: vi.fn(), +} + export const crashReporter = { start: vi.fn(), }