Skip to content

Commit 47cd071

Browse files
committed
fix(desktop): fall back from invalid releases
1 parent 8fa8ae4 commit 47cd071

9 files changed

Lines changed: 297 additions & 155 deletions

File tree

apps/desktop/README.md

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

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

174-
- `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. Install is prompt-based (Restart and update / Later; Later installs on quit) — never forced mid-session.
174+
- `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.
175175
- 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.
176176
- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
177177
- 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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,37 @@ describe('initUpdater state machine', () => {
353353
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
354354
})
355355

356+
it('does not install when the updater fails during pre-install teardown', async () => {
357+
let finishTeardown: (() => void) | undefined
358+
const setRelaunchPending = vi.fn()
359+
const { handle } = await createUpdater({
360+
beforeInstall: () =>
361+
new Promise<void>((resolve) => {
362+
finishTeardown = resolve
363+
}),
364+
setRelaunchPending,
365+
})
366+
367+
handle.check()
368+
await vi.advanceTimersByTimeAsync(0)
369+
emit('update-available', { version: '2.0.0' })
370+
emit('update-downloaded', { version: '2.0.0' })
371+
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
372+
response: 1,
373+
checkboxChecked: false,
374+
})
375+
handle.install()
376+
await vi.advanceTimersByTimeAsync(0)
377+
378+
emit('error', new Error('native staging failed'))
379+
finishTeardown?.()
380+
await vi.advanceTimersByTimeAsync(0)
381+
382+
expect(handle.getState()).toEqual({ status: 'error', version: '2.0.0' })
383+
expect(setRelaunchPending).not.toHaveBeenCalledWith(true)
384+
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
385+
})
386+
356387
it('bypasses renderer unload guards only after teardown succeeds', async () => {
357388
const setRelaunchPending = vi.fn()
358389
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({

apps/desktop/src/main/updater.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,12 +394,17 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
394394
let installInFlight = false
395395
let installConfirmationInFlight = false
396396

397-
const quitAndInstall = () => {
397+
const quitAndInstall = (version: string | undefined) => {
398398
if (installInFlight) return
399399
installInFlight = true
400400
void Promise.resolve()
401401
.then(() => deps.beforeInstall?.())
402402
.then(() => {
403+
if (state.status !== 'ready' || state.version !== version) {
404+
autoUpdater.autoInstallOnAppQuit = false
405+
installInFlight = false
406+
return
407+
}
403408
deps.setRelaunchPending?.(true)
404409
autoUpdater.quitAndInstall()
405410
})
@@ -435,7 +440,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
435440
void confirmation
436441
.then(({ response }) => {
437442
if (response === 1 && state.status === 'ready' && state.version === version) {
438-
quitAndInstall()
443+
quitAndInstall(version)
439444
}
440445
})
441446
.catch((error) => {

apps/sim/app/api/desktop/update/download/route.test.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,32 @@ function release(tag: string, repository: string) {
3636
}
3737
}
3838

39+
function manifest(version: string) {
40+
return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal.zip`].join('\n')
41+
}
42+
3943
async function getDownload(): Promise<Response> {
4044
return GET(new NextRequest('https://www.sim.ai/api/desktop/update/download'), undefined)
4145
}
4246

4347
describe('desktop update download route', () => {
4448
const fetchMock = vi.fn()
4549

50+
function mockReleases(releases: ReturnType<typeof release>[]) {
51+
fetchMock.mockImplementation(async (input: string | URL | Request) => {
52+
const url = String(input)
53+
if (url === STABLE_RELEASES_URL || url === PRERELEASE_RELEASES_URL) {
54+
return Response.json(releases)
55+
}
56+
const candidate = releases.find((release) =>
57+
release.assets.some((asset) => asset.browser_download_url === url)
58+
)
59+
return candidate
60+
? new Response(manifest(candidate.tag_name.replace(/^v/, '')))
61+
: new Response(null, { status: 404 })
62+
})
63+
}
64+
4665
beforeEach(() => {
4766
fetchMock.mockReset()
4867
vi.stubGlobal('fetch', fetchMock)
@@ -54,13 +73,11 @@ describe('desktop update download route', () => {
5473
})
5574

5675
it('redirects to the newest stable installer', async () => {
57-
fetchMock.mockResolvedValueOnce(
58-
Response.json([
59-
release('v1.1.0', DESKTOP_STABLE_RELEASE_REPOSITORY),
60-
release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY),
61-
release('v1.2.0', DESKTOP_STABLE_RELEASE_REPOSITORY),
62-
])
63-
)
76+
mockReleases([
77+
release('v1.1.0', DESKTOP_STABLE_RELEASE_REPOSITORY),
78+
release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY),
79+
release('v1.2.0', DESKTOP_STABLE_RELEASE_REPOSITORY),
80+
])
6481

6582
const response = await getDownload()
6683

@@ -73,12 +90,10 @@ describe('desktop update download route', () => {
7390

7491
it('serves its own deployment channel rather than the stable stream', async () => {
7592
setEnv({ APPCONFIG_ENVIRONMENT: 'dev' })
76-
fetchMock.mockResolvedValueOnce(
77-
Response.json([
78-
release('v1.3.0-dev.4', DESKTOP_PRERELEASE_REPOSITORY),
79-
release('v1.4.0-staging.1', DESKTOP_PRERELEASE_REPOSITORY),
80-
])
81-
)
93+
mockReleases([
94+
release('v1.3.0-dev.4', DESKTOP_PRERELEASE_REPOSITORY),
95+
release('v1.4.0-staging.1', DESKTOP_PRERELEASE_REPOSITORY),
96+
])
8297

8398
const response = await getDownload()
8499

@@ -87,6 +102,17 @@ describe('desktop update download route', () => {
87102
expect(fetchMock).toHaveBeenCalledWith(PRERELEASE_RELEASES_URL, expect.any(Object))
88103
})
89104

105+
it('falls back when the newest release has no installer artifact', async () => {
106+
const incomplete = release('v1.4.0', DESKTOP_STABLE_RELEASE_REPOSITORY)
107+
incomplete.assets = incomplete.assets.filter((asset) => asset.name === MANIFEST_ASSET_NAME)
108+
mockReleases([incomplete, release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY)])
109+
110+
const response = await getDownload()
111+
112+
expect(response.status).toBe(302)
113+
expect(response.headers.get('location')).toContain('Sim-1.3.0-universal.dmg')
114+
})
115+
90116
it('reports no release when the channel has none', async () => {
91117
fetchMock.mockResolvedValueOnce(
92118
Response.json([release('v1.3.0-dev.4', DESKTOP_PRERELEASE_REPOSITORY)])

apps/sim/app/api/desktop/update/download/route.ts

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
releaseRepositoryForChannel,
1010
releasesApiUrl,
1111
resolveLatestRelease,
12-
selectInstallerAsset,
12+
resolveReleaseAssets,
1313
} from '@/lib/desktop/update-feed'
1414

1515
const logger = createLogger('DesktopUpdateDownloadAPI')
@@ -36,52 +36,64 @@ export const GET = withRouteHandler(async (_request: NextRequest): Promise<Respo
3636
const releaseRepository = releaseRepositoryForChannel(channel)
3737

3838
const githubToken = env.GITHUB_TOKEN
39-
const resolved = await resolveLatestRelease(channel, async (page) => {
40-
try {
41-
const response = await fetch(releasesApiUrl(releaseRepository, page), {
42-
headers: {
43-
accept: 'application/vnd.github+json',
44-
...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}),
45-
},
46-
next: { revalidate: REVALIDATE_SECONDS },
47-
})
48-
if (!response.ok) {
49-
logger.error('GitHub releases lookup failed', {
50-
status: response.status,
39+
const resolved = await resolveLatestRelease(
40+
channel,
41+
async (page) => {
42+
try {
43+
const response = await fetch(releasesApiUrl(releaseRepository, page), {
44+
headers: {
45+
accept: 'application/vnd.github+json',
46+
...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}),
47+
},
48+
next: { revalidate: REVALIDATE_SECONDS },
49+
})
50+
if (!response.ok) {
51+
logger.error('GitHub releases lookup failed', {
52+
status: response.status,
53+
page,
54+
channel,
55+
releaseRepository,
56+
})
57+
return null
58+
}
59+
return (await response.json()) as DesktopReleaseCandidate[]
60+
} catch (error) {
61+
logger.error('GitHub releases response could not be read', {
62+
message: getErrorMessage(error),
5163
page,
5264
channel,
5365
releaseRepository,
5466
})
5567
return null
5668
}
57-
return (await response.json()) as DesktopReleaseCandidate[]
58-
} catch (error) {
59-
logger.error('GitHub releases response could not be read', {
60-
message: getErrorMessage(error),
61-
page,
62-
channel,
63-
releaseRepository,
64-
})
65-
return null
69+
},
70+
async (release) => {
71+
const assets = await resolveReleaseAssets(release, releaseRepository, (url) =>
72+
fetch(url, {
73+
next: { revalidate: REVALIDATE_SECONDS },
74+
})
75+
)
76+
if (!assets) {
77+
logger.warn('Skipping incomplete or invalid desktop release', {
78+
tag: release.tag_name,
79+
channel,
80+
})
81+
}
82+
return assets?.installer ?? null
6683
}
67-
})
84+
)
6885
if ('error' in resolved) {
6986
return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 })
7087
}
7188

72-
const release = resolved.release
73-
const asset = release ? selectInstallerAsset(release, releaseRepository) : null
74-
if (!release || !asset) {
75-
if (release) {
76-
logger.error('Release has no installer artifact', { tag: release.tag_name, channel })
77-
}
89+
if (!resolved.release) {
7890
return NextResponse.json(
7991
{ error: `No desktop release for channel ${channel}` },
8092
{ status: 404 }
8193
)
8294
}
8395

84-
return NextResponse.redirect(asset.browser_download_url, {
96+
return NextResponse.redirect(resolved.value.browser_download_url, {
8597
status: 302,
8698
headers: { 'cache-control': `public, max-age=${REVALIDATE_SECONDS}` },
8799
})

apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const PRERELEASE_RELEASES_URL = releasesApiUrl(DESKTOP_PRERELEASE_REPOSITORY, 1)
1818
const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed'
1919

2020
function release(tag: string) {
21+
const version = tag.replace(/^v/, '')
2122
return {
2223
tag_name: tag,
2324
draft: false,
@@ -27,6 +28,14 @@ function release(tag: string) {
2728
name: MANIFEST_ASSET_NAME,
2829
browser_download_url: `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`,
2930
},
31+
{
32+
name: `Sim-${version}-universal.zip`,
33+
browser_download_url: `https://downloads.example/${tag}/Sim-${version}-universal.zip`,
34+
},
35+
{
36+
name: `Sim-${version}-universal.dmg`,
37+
browser_download_url: `https://downloads.example/${tag}/Sim-${version}-universal.dmg`,
38+
},
3039
],
3140
}
3241
}
@@ -226,6 +235,28 @@ describe('desktop update manifest route', () => {
226235
expect(fetchMock).toHaveBeenNthCalledWith(1, PRERELEASE_RELEASES_URL, expect.any(Object))
227236
})
228237

238+
it('falls back when the newest release has an invalid manifest', async () => {
239+
setEnv({ APPCONFIG_ENVIRONMENT: 'dev' })
240+
fetchMock.mockImplementation(async (input: string | URL | Request) => {
241+
const url = String(input)
242+
if (url === PRERELEASE_RELEASES_URL) {
243+
return Response.json([release('v1.2.0-dev.5'), release('v1.2.0-dev.4')])
244+
}
245+
if (url === `https://downloads.example/v1.2.0-dev.5/${MANIFEST_ASSET_NAME}`) {
246+
return new Response(manifest('1.2.0-staging.5'))
247+
}
248+
if (url === `https://downloads.example/v1.2.0-dev.4/${MANIFEST_ASSET_NAME}`) {
249+
return new Response(manifest('1.2.0-dev.4'))
250+
}
251+
return new Response(null, { status: 404 })
252+
})
253+
254+
const response = await getFeed('www.dev.sim.ai')
255+
256+
expect(response.status).toBe(200)
257+
expect(await response.text()).toContain('version: 1.2.0-dev.4')
258+
})
259+
229260
it('rejects an oversized updater manifest', async () => {
230261
fetchMock
231262
.mockResolvedValueOnce(Response.json([release('v1.1.0')]))

0 commit comments

Comments
 (0)