Skip to content

Commit fb2c3f3

Browse files
authored
fix(desktop): replace a stale staged update with the newest release (#8186)
* fix(desktop): replace a stale staged update with the newest release * fix(desktop): track Squirrel staging so update refreshes never cancel an install * fix(desktop): clear a cancelled update replacement so refreshes resume
1 parent ea9e85f commit fb2c3f3

4 files changed

Lines changed: 415 additions & 23 deletions

File tree

apps/desktop/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
172172

173173
## Auto-update, channels, rollout, rollback
174174

175-
- `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.
175+
- `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.
176176
- 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.
177177
- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
178178
- 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.)

apps/desktop/src/main/updater.test.ts

Lines changed: 267 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const autoUpdaterMock = {
2424
quitAndInstall: vi.fn(),
2525
}
2626

27-
import { app, dialog, shell } from 'electron'
27+
import { app, dialog, shell, autoUpdater as squirrelUpdater } from 'electron'
2828
import {
2929
checkForUpdatesInteractive,
3030
feedUrlForOrigin,
@@ -153,6 +153,24 @@ describe('initUpdater state machine', () => {
153153
}
154154
}
155155

156+
/** Replays a native Squirrel.Mac event, e.g. `update-downloaded` once a bundle is staged. */
157+
function emitSquirrel(event: string) {
158+
for (const [name, listener] of vi.mocked(squirrelUpdater.on).mock.calls) {
159+
if (name === event) {
160+
;(listener as () => void)()
161+
}
162+
}
163+
}
164+
165+
/** Drives a fresh updater to a Squirrel-staged `ready` update for `version`. */
166+
async function stageUpdate(handle: UpdaterHandle, version: string) {
167+
handle.check()
168+
await vi.advanceTimersByTimeAsync(0)
169+
emit('update-available', { version })
170+
emit('update-downloaded', { version })
171+
emitSquirrel('update-downloaded')
172+
}
173+
156174
async function createUpdater(options?: {
157175
autoDownload?: boolean
158176
feedAvailable?: boolean | 'no-release'
@@ -183,6 +201,7 @@ describe('initUpdater state machine', () => {
183201
beforeEach(() => {
184202
vi.useFakeTimers()
185203
autoUpdaterMock.on.mockClear()
204+
vi.mocked(squirrelUpdater.on).mockClear()
186205
autoUpdaterMock.setFeedURL.mockClear()
187206
autoUpdaterMock.checkForUpdates.mockClear()
188207
autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {}))
@@ -445,6 +464,228 @@ describe('initUpdater state machine', () => {
445464
})
446465
})
447466

467+
it('replaces a staged update with a newer release instead of installing the stale build', async () => {
468+
const { handle, states } = await createUpdater()
469+
await stageUpdate(handle, '2.0.0')
470+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
471+
states.length = 0
472+
473+
await vi.advanceTimersByTimeAsync(10_000)
474+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
475+
emit('checking-for-update')
476+
emit('update-available', { version: '2.1.0' })
477+
emit('download-progress', { percent: 50 })
478+
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(2)
479+
480+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
481+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
482+
483+
emit('update-downloaded', { version: '2.1.0' })
484+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
485+
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true)
486+
487+
emitSquirrel('update-downloaded')
488+
expect(states).toEqual([{ status: 'ready', version: '2.1.0' }])
489+
expect(events.record).toHaveBeenCalledWith('update_downloaded', { version: '2.1.0' })
490+
})
491+
492+
it('does not re-check a ready update until Squirrel has staged it', async () => {
493+
const { handle } = await createUpdater()
494+
handle.check()
495+
await vi.advanceTimersByTimeAsync(0)
496+
emit('update-available', { version: '2.0.0' })
497+
emit('update-downloaded', { version: '2.0.0' })
498+
499+
await vi.advanceTimersByTimeAsync(10_000)
500+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
501+
502+
emitSquirrel('update-downloaded')
503+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000)
504+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
505+
})
506+
507+
it('keeps a staged update when a background re-check finds nothing newer or fails', async () => {
508+
const { handle, states } = await createUpdater()
509+
await stageUpdate(handle, '2.0.0')
510+
states.length = 0
511+
512+
await vi.advanceTimersByTimeAsync(10_000)
513+
emit('update-available', { version: '2.0.0' })
514+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000)
515+
emit('update-not-available')
516+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
517+
emit('error', new Error('net::ERR_NETWORK_CHANGED'))
518+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
519+
emit('update-available', { version: '2.1.0' })
520+
emit('error', new Error('download interrupted'))
521+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
522+
emit('update-available', { version: '2.2.0' })
523+
emit('update-downloaded', { version: '2.2.0' })
524+
emit('error', new Error('Squirrel could not verify the replacement'))
525+
526+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(6)
527+
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(3)
528+
expect(states).toEqual([])
529+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
530+
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true)
531+
532+
emitSquirrel('update-downloaded')
533+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
534+
})
535+
536+
it('does not replace a staged update when background downloads are disabled', async () => {
537+
const { handle } = await createUpdater()
538+
await stageUpdate(handle, '2.0.0')
539+
handle.setAutoDownload(false)
540+
541+
await vi.advanceTimersByTimeAsync(10_000)
542+
543+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
544+
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1)
545+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
546+
})
547+
548+
it('completes a confirmed restart when a background re-check fails during teardown', async () => {
549+
let finishTeardown: (() => void) | undefined
550+
const setRelaunchPending = vi.fn()
551+
const { handle } = await createUpdater({
552+
beforeInstall: () =>
553+
new Promise<void>((resolve) => {
554+
finishTeardown = resolve
555+
}),
556+
setRelaunchPending,
557+
})
558+
await stageUpdate(handle, '2.0.0')
559+
await vi.advanceTimersByTimeAsync(10_000)
560+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
561+
562+
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
563+
response: 1,
564+
checkboxChecked: false,
565+
})
566+
handle.install()
567+
await vi.advanceTimersByTimeAsync(0)
568+
emit('error', new Error('net::ERR_NETWORK_CHANGED'))
569+
finishTeardown?.()
570+
await vi.advanceTimersByTimeAsync(0)
571+
572+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
573+
expect(setRelaunchPending).toHaveBeenCalledWith(true)
574+
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
575+
})
576+
577+
it('surfaces a relaunch failure after a confirmed restart', async () => {
578+
const setRelaunchPending = vi.fn()
579+
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
580+
response: 1,
581+
checkboxChecked: false,
582+
})
583+
const { handle } = await createUpdater({ beforeInstall: async () => {}, setRelaunchPending })
584+
await stageUpdate(handle, '2.0.0')
585+
handle.install()
586+
await vi.advanceTimersByTimeAsync(0)
587+
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
588+
589+
emit('error', new Error('ShipIt could not launch'))
590+
591+
expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' })
592+
expect(setRelaunchPending).toHaveBeenLastCalledWith(false)
593+
})
594+
595+
it('installs a replacement that finished staging while the restart prompt was open', async () => {
596+
let resolveConfirmation: (result: { response: number; checkboxChecked: boolean }) => void =
597+
() => {
598+
throw new Error('Restart confirmation did not initialize')
599+
}
600+
const { handle } = await createUpdater()
601+
await stageUpdate(handle, '2.0.0')
602+
await vi.advanceTimersByTimeAsync(10_000)
603+
emit('update-available', { version: '2.1.0' })
604+
605+
vi.mocked(dialog.showMessageBox).mockImplementationOnce(
606+
() =>
607+
new Promise((resolve) => {
608+
resolveConfirmation = resolve
609+
})
610+
)
611+
handle.install()
612+
emit('update-downloaded', { version: '2.1.0' })
613+
emitSquirrel('update-downloaded')
614+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' })
615+
resolveConfirmation({ response: 1, checkboxChecked: false })
616+
await vi.advanceTimersByTimeAsync(0)
617+
618+
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
619+
})
620+
621+
it('refreshes an offered update to a newer release before it is downloaded', async () => {
622+
const { handle } = await createUpdater({ autoDownload: false })
623+
handle.check()
624+
await vi.advanceTimersByTimeAsync(0)
625+
emit('update-available', { version: '2.0.0' })
626+
expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' })
627+
628+
await vi.advanceTimersByTimeAsync(10_000)
629+
emit('checking-for-update')
630+
emit('error', new Error('net::ERR_INTERNET_DISCONNECTED'))
631+
expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' })
632+
633+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000)
634+
emit('update-not-available')
635+
expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0' })
636+
637+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
638+
emit('update-available', { version: '2.1.0' })
639+
expect(handle.getState()).toEqual({ status: 'available', version: '2.1.0' })
640+
expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled()
641+
})
642+
643+
it('resumes refreshing after a replacement download is cancelled without an error event', async () => {
644+
const { handle } = await createUpdater()
645+
await stageUpdate(handle, '2.0.0')
646+
await vi.advanceTimersByTimeAsync(10_000)
647+
autoUpdaterMock.downloadUpdate.mockImplementationOnce(() =>
648+
Promise.reject(new Error('cancelled'))
649+
)
650+
emit('update-available', { version: '2.1.0' })
651+
await vi.advanceTimersByTimeAsync(0)
652+
653+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000)
654+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3)
655+
emit('update-available', { version: '2.1.0' })
656+
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(3)
657+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
658+
})
659+
660+
it('follows the feed when a re-check rolls an offered release back', async () => {
661+
const { handle } = await createUpdater({ autoDownload: false })
662+
handle.check()
663+
await vi.advanceTimersByTimeAsync(0)
664+
emit('update-available', { version: '2.2.0' })
665+
666+
await vi.advanceTimersByTimeAsync(10_000)
667+
emit('update-available', { version: '2.1.0' })
668+
expect(handle.getState()).toEqual({ status: 'available', version: '2.1.0' })
669+
670+
handle.check()
671+
emit('update-downloaded', { version: '2.1.0' })
672+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' })
673+
})
674+
675+
it('withdraws an offered update once a re-check stores a blocked candidate', async () => {
676+
const { handle } = await createUpdater({ autoDownload: false })
677+
handle.check()
678+
await vi.advanceTimersByTimeAsync(0)
679+
emit('update-available', { version: '2.0.0' })
680+
681+
await vi.advanceTimersByTimeAsync(10_000)
682+
emit('update-available', { version: '2.1.0-dev.1' })
683+
684+
expect(handle.getState()).toEqual({ status: 'idle' })
685+
handle.check()
686+
expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled()
687+
})
688+
448689
it('checks from idle and ignores re-entrant checks while busy', async () => {
449690
const { handle } = await createUpdater()
450691
handle.check()
@@ -1035,6 +1276,31 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
10351276
expect(handle.getState()).toEqual({ status: 'error', manual: true })
10361277
})
10371278

1279+
it('replaces an offered manual download with a newer release', async () => {
1280+
let feedVersion: string | null = '2.0.0'
1281+
const fetchManifest = vi.fn(async () => {
1282+
if (feedVersion === null) throw new Error('network down')
1283+
return manifest(feedVersion)
1284+
})
1285+
const { handle } = await createManualUpdater(fetchManifest)
1286+
handle.check()
1287+
await vi.advanceTimersByTimeAsync(0)
1288+
expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0', manual: true })
1289+
1290+
feedVersion = null
1291+
await vi.advanceTimersByTimeAsync(10_000)
1292+
expect(handle.getState()).toEqual({ status: 'available', version: '2.0.0', manual: true })
1293+
1294+
feedVersion = '2.1.0'
1295+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
1296+
expect(handle.getState()).toEqual({ status: 'available', version: '2.1.0', manual: true })
1297+
1298+
handle.install()
1299+
expect(shell.openExternal).toHaveBeenCalledWith(
1300+
'https://github.com/simstudioai/sim/releases/download/v2.1.0/Sim-2.1.0-universal.dmg'
1301+
)
1302+
})
1303+
10381304
it('checks on the scheduled interval', async () => {
10391305
const fetchManifest = vi.fn(async () => manifest('9.9.9'))
10401306
await createManualUpdater(fetchManifest)

0 commit comments

Comments
 (0)