From 24c885070a407ff023287650e1627e86b8f138e5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 15:07:12 -0700 Subject: [PATCH] fix(desktop): guarantee the update feed resolves the newest release The stable channel reads a GitHub release list it shares with web-app releases, SDK tags, and legacy prereleases, but only ever looked at the first 30 entries. Once enough unrelated releases stack on top, the feed 404s and every stable shell silently stops updating. Walk pages (100 per page, up to 5) until one yields a release for the channel, and fail the feed rather than serving an older build when a page cannot be read. Also point the update gate's manual download at a new /api/desktop/update/download redirect, which resolves through the same channel selection. It previously opened GitHub's repository-wide latest release, which can be a tag carrying no desktop artifact at all. --- apps/sim/app/_shell/desktop-update-gate.tsx | 17 ++- .../api/desktop/update/download/route.test.ts | 111 ++++++++++++++++++ .../app/api/desktop/update/download/route.ts | 77 ++++++++++++ .../update/latest-mac.yml/route.test.ts | 61 +++++++++- .../desktop/update/latest-mac.yml/route.ts | 38 +++--- apps/sim/lib/desktop/update-feed.ts | 64 ++++++++++ scripts/check-api-validation-contracts.ts | 7 +- 7 files changed, 354 insertions(+), 21 deletions(-) create mode 100644 apps/sim/app/api/desktop/update/download/route.test.ts create mode 100644 apps/sim/app/api/desktop/update/download/route.ts diff --git a/apps/sim/app/_shell/desktop-update-gate.tsx b/apps/sim/app/_shell/desktop-update-gate.tsx index 8a622f5c188..f9c114cfc43 100644 --- a/apps/sim/app/_shell/desktop-update-gate.tsx +++ b/apps/sim/app/_shell/desktop-update-gate.tsx @@ -6,8 +6,23 @@ import { Button, useNativeSurfaceOcclusionReady } from '@sim/emcn' import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop' import { isShellOutdated } from '@/lib/desktop/min-version' +/** + * Resolves this deployment's channel to the newest release's installer, so a + * manual download lands on the same build the updater would have installed. + */ +const DOWNLOAD_REDIRECT_PATH = '/api/desktop/update/download' + const DOWNLOAD_FALLBACK_URL = 'https://github.com/simstudioai/sim/releases/latest' +function manualDownloadUrl(): string { + const origin = window.location.origin + // openExternal only accepts https, so an http self-hosted origin cannot + // serve the redirect to the system browser. + return origin.startsWith('https://') + ? `${origin}${DOWNLOAD_REDIRECT_PATH}` + : DOWNLOAD_FALLBACK_URL +} + interface GateAction { label: string disabled?: boolean @@ -21,7 +36,7 @@ function gateActionFor(state: DesktopUpdateState): GateAction { // background; the button covers the manual path. return { label: 'Get the latest version', - onClick: () => void getDesktopBridge()?.openExternal(DOWNLOAD_FALLBACK_URL), + onClick: () => void getDesktopBridge()?.openExternal(manualDownloadUrl()), } } switch (state.status) { diff --git a/apps/sim/app/api/desktop/update/download/route.test.ts b/apps/sim/app/api/desktop/update/download/route.test.ts new file mode 100644 index 00000000000..c85a16cce05 --- /dev/null +++ b/apps/sim/app/api/desktop/update/download/route.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import { setEnv } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DESKTOP_PRERELEASE_REPOSITORY, + DESKTOP_STABLE_RELEASE_REPOSITORY, + MANIFEST_ASSET_NAME, + releasesApiUrl, +} from '@/lib/desktop/update-feed' +import { GET } from '@/app/api/desktop/update/download/route' + +const STABLE_RELEASES_URL = releasesApiUrl(DESKTOP_STABLE_RELEASE_REPOSITORY, 1) +const PRERELEASE_RELEASES_URL = releasesApiUrl(DESKTOP_PRERELEASE_REPOSITORY, 1) + +function release(tag: string, repository: string) { + const base = `https://github.com/${repository}/releases/download/${tag}` + const version = tag.replace(/^v/, '') + return { + tag_name: tag, + draft: false, + prerelease: tag.includes('-'), + assets: [ + { name: MANIFEST_ASSET_NAME, browser_download_url: `${base}/${MANIFEST_ASSET_NAME}` }, + { + name: `Sim-${version}-universal.zip`, + browser_download_url: `${base}/Sim-${version}-universal.zip`, + }, + { + name: `Sim-${version}-universal.dmg`, + browser_download_url: `${base}/Sim-${version}-universal.dmg`, + }, + ], + } +} + +async function getDownload(): Promise { + return GET(new NextRequest('https://www.sim.ai/api/desktop/update/download'), undefined) +} + +describe('desktop update download route', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + setEnv({ APPCONFIG_ENVIRONMENT: undefined }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('redirects to the newest stable installer', async () => { + fetchMock.mockResolvedValueOnce( + Response.json([ + release('v1.1.0', DESKTOP_STABLE_RELEASE_REPOSITORY), + release('v1.3.0', DESKTOP_STABLE_RELEASE_REPOSITORY), + release('v1.2.0', DESKTOP_STABLE_RELEASE_REPOSITORY), + ]) + ) + + const response = await getDownload() + + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe( + `https://github.com/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases/download/v1.3.0/Sim-1.3.0-universal.dmg` + ) + expect(fetchMock).toHaveBeenCalledWith(STABLE_RELEASES_URL, expect.any(Object)) + }) + + it('serves its own deployment channel rather than the stable stream', async () => { + setEnv({ APPCONFIG_ENVIRONMENT: 'dev' }) + fetchMock.mockResolvedValueOnce( + Response.json([ + release('v1.3.0-dev.4', DESKTOP_PRERELEASE_REPOSITORY), + release('v1.4.0-staging.1', DESKTOP_PRERELEASE_REPOSITORY), + ]) + ) + + const response = await getDownload() + + expect(response.status).toBe(302) + expect(response.headers.get('location')).toContain('Sim-1.3.0-dev.4-universal.dmg') + expect(fetchMock).toHaveBeenCalledWith(PRERELEASE_RELEASES_URL, expect.any(Object)) + }) + + it('reports no release when the channel has none', async () => { + fetchMock.mockResolvedValueOnce( + Response.json([release('v1.3.0-dev.4', DESKTOP_PRERELEASE_REPOSITORY)]) + ) + + const response = await getDownload() + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: 'No desktop release for channel latest', + }) + }) + + it('surfaces an unreadable release list instead of redirecting', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })) + + const response = await getDownload() + + expect(response.status).toBe(502) + expect(await response.json()).toMatchObject({ error: 'Release feed unavailable' }) + }) +}) diff --git a/apps/sim/app/api/desktop/update/download/route.ts b/apps/sim/app/api/desktop/update/download/route.ts new file mode 100644 index 00000000000..520e4761452 --- /dev/null +++ b/apps/sim/app/api/desktop/update/download/route.ts @@ -0,0 +1,77 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + channelForDeploymentEnvironment, + type DesktopReleaseCandidate, + releaseRepositoryForChannel, + releasesApiUrl, + resolveLatestRelease, + selectInstallerAsset, +} from '@/lib/desktop/update-feed' + +const logger = createLogger('DesktopUpdateDownloadAPI') + +/** Matches the manifest feed so both paths resolve the same release. */ +const REVALIDATE_SECONDS = 300 + +/** + * Redirects to the installer for the newest release of this deployment's + * channel (see `lib/desktop/update-feed.ts`). + * + * This is the manual escape hatch behind the blocking update gate: shells too + * old to expose the updater bridge send the user here instead of + * self-updating. It resolves through the same channel selection as the + * manifest feed, so a manual download lands on exactly the build + * electron-updater would have installed — never an intermediate version, and + * never a repository release carrying no desktop artifact. + * + * Public by the same reasoning as the manifest feed: it only points at public + * GitHub release assets. + */ +export const GET = withRouteHandler(async (_request: NextRequest): Promise => { + const channel = channelForDeploymentEnvironment(env.APPCONFIG_ENVIRONMENT) + const releaseRepository = releaseRepositoryForChannel(channel) + + const githubToken = process.env.GITHUB_TOKEN + const resolved = await resolveLatestRelease(channel, async (page) => { + const response = await fetch(releasesApiUrl(releaseRepository, page), { + headers: { + accept: 'application/vnd.github+json', + ...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}), + }, + next: { revalidate: REVALIDATE_SECONDS }, + }) + if (!response.ok) { + logger.error('GitHub releases lookup failed', { + status: response.status, + page, + channel, + releaseRepository, + }) + return null + } + return (await response.json()) as DesktopReleaseCandidate[] + }) + if ('error' in resolved) { + return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) + } + + const release = resolved.release + const asset = release ? selectInstallerAsset(release) : null + if (!release || !asset) { + if (release) { + logger.error('Release has no installer artifact', { tag: release.tag_name, channel }) + } + return NextResponse.json( + { error: `No desktop release for channel ${channel}` }, + { status: 404 } + ) + } + + return NextResponse.redirect(asset.browser_download_url, { + status: 302, + headers: { 'cache-control': `public, max-age=${REVALIDATE_SECONDS}` }, + }) +}) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts index 754b5fff8b4..256551d910a 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts @@ -6,13 +6,15 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DESKTOP_PRERELEASE_REPOSITORY, + DESKTOP_RELEASES_PAGE_SIZE, DESKTOP_STABLE_RELEASE_REPOSITORY, MANIFEST_ASSET_NAME, + releasesApiUrl, } from '@/lib/desktop/update-feed' import { GET } from '@/app/api/desktop/update/latest-mac.yml/route' -const STABLE_RELEASES_URL = `https://api.github.com/repos/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases?per_page=30` -const PRERELEASE_RELEASES_URL = `https://api.github.com/repos/${DESKTOP_PRERELEASE_REPOSITORY}/releases?per_page=30` +const STABLE_RELEASES_URL = releasesApiUrl(DESKTOP_STABLE_RELEASE_REPOSITORY, 1) +const PRERELEASE_RELEASES_URL = releasesApiUrl(DESKTOP_PRERELEASE_REPOSITORY, 1) const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' function release(tag: string) { @@ -156,6 +158,61 @@ describe('desktop update manifest route', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) + it('walks past a page of unrelated releases to reach the newest desktop build', async () => { + const filler = Array.from({ length: DESKTOP_RELEASES_PAGE_SIZE }, (_, index) => ({ + tag_name: `python-sdk-v0.${index}.0`, + draft: false, + prerelease: false, + assets: [], + })) + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input) + if (url === releasesApiUrl(DESKTOP_STABLE_RELEASE_REPOSITORY, 1)) { + return Response.json(filler) + } + if (url === releasesApiUrl(DESKTOP_STABLE_RELEASE_REPOSITORY, 2)) { + return Response.json([release('v1.1.0')]) + } + if (url === `https://downloads.example/v1.1.0/${MANIFEST_ASSET_NAME}`) { + return new Response(manifest('1.1.0')) + } + return new Response(null, { status: 404 }) + }) + + const response = await getFeed('www.sim.ai') + + expect(response.status).toBe(200) + expect(await response.text()).toContain('version: 1.1.0') + }) + + it('stops walking at a short page rather than requesting empty ones', async () => { + fetchMock.mockResolvedValueOnce( + Response.json([release('v1.2.0-dev.4'), release('v1.2.0-staging.5')]) + ) + + const response = await getFeed('www.sim.ai') + + expect(response.status).toBe(404) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('fails the feed instead of serving an older release when a page cannot be read', async () => { + const filler = Array.from({ length: DESKTOP_RELEASES_PAGE_SIZE }, (_, index) => ({ + tag_name: `python-sdk-v0.${index}.0`, + draft: false, + prerelease: false, + assets: [], + })) + fetchMock + .mockResolvedValueOnce(Response.json(filler)) + .mockResolvedValueOnce(new Response(null, { status: 500 })) + + const response = await getFeed('www.sim.ai') + + expect(response.status).toBe(502) + expect(await response.json()).toMatchObject({ error: 'Release feed unavailable' }) + }) + it('rejects a manifest whose version does not match its selected release', async () => { setEnv({ APPCONFIG_ENVIRONMENT: 'dev' }) fetchMock diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts index a731ba3be6f..6c443caa43e 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts @@ -7,8 +7,9 @@ import { type DesktopReleaseCandidate, MANIFEST_ASSET_NAME, releaseRepositoryForChannel, + releasesApiUrl, + resolveLatestRelease, rewriteManifestUrls, - selectReleaseForChannel, } from '@/lib/desktop/update-feed' const logger = createLogger('DesktopUpdateFeedAPI') @@ -37,29 +38,34 @@ export const GET = withRouteHandler(async (_request: NextRequest): Promise { + const response = await fetch(releasesApiUrl(releaseRepository, page), { + headers: { + accept: 'application/vnd.github+json', + ...(githubToken ? { authorization: `Bearer ${githubToken}` } : {}), + }, + next: { revalidate: REVALIDATE_SECONDS }, }) + if (!response.ok) { + logger.error('GitHub releases lookup failed', { + status: response.status, + page, + channel, + releaseRepository, + }) + return null + } + return (await response.json()) as DesktopReleaseCandidate[] + }) + if ('error' in resolved) { return NextResponse.json({ error: 'Release feed unavailable' }, { status: 502 }) } - const releases = (await releasesResponse.json()) as DesktopReleaseCandidate[] - const release = selectReleaseForChannel(releases, channel) + const release = resolved.release if (!release) { return NextResponse.json( { error: `No desktop release for channel ${channel}` }, diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index b3b59f8be17..ed8d14c16f3 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -134,3 +134,67 @@ export function rewriteManifestUrls( return `${prefix}${base}${encodeURIComponent(value)}` }) } + +/** + * GitHub's maximum page size for the releases API. The stable channel reads a + * release list it shares with web-app releases, SDK releases, and legacy + * prereleases, so the window has to be wide enough that desktop releases are + * never pushed out of it. + */ +export const DESKTOP_RELEASES_PAGE_SIZE = 100 + +/** + * How far back the resolver walks before giving up. A channel whose newest + * release is buried deeper than this is already unreachable to its clients, + * and an unbounded walk would let an unrelated tag family stall the feed. + */ +export const MAX_DESKTOP_RELEASE_PAGES = 5 + +/** One page of the GitHub releases API, newest release first. */ +export function releasesApiUrl(repository: DesktopReleaseRepository, page: number): string { + return `https://api.github.com/repos/${repository}/releases?per_page=${DESKTOP_RELEASES_PAGE_SIZE}&page=${page}` +} + +/** + * The newest release of a channel, walking pages until one yields a match. + * + * GitHub returns releases newest-first, so the first page containing any + * release of the channel also contains its newest one — every later page is + * strictly older. The walk exists only so unrelated releases stacked on top + * (other tag families, other channels) cannot push a channel's newest build + * out of the window and take the whole channel's updates down. + * + * `fetchPage` returns null when the page could not be read; the resolver + * surfaces that as a failure rather than silently serving an older release. + */ +export async function resolveLatestRelease( + channel: DesktopUpdateChannel, + fetchPage: (page: number) => Promise +): Promise<{ release: DesktopReleaseCandidate | null } | { error: 'fetch-failed' }> { + for (let page = 1; page <= MAX_DESKTOP_RELEASE_PAGES; page++) { + const releases = await fetchPage(page) + if (releases === null) return { error: 'fetch-failed' } + const release = selectReleaseForChannel(releases, channel) + if (release) return { release } + // A short page is the end of the list; nothing older remains to walk. + if (releases.length < DESKTOP_RELEASES_PAGE_SIZE) break + } + return { release: null } +} + +/** + * The human-installable artifact of a release, preferred over the zip the + * updater consumes. Selected per-release rather than through GitHub's + * repository-wide "latest release", which the stable repository shares with + * web-app and SDK tags that carry no desktop artifact at all. + */ +export function selectInstallerAsset( + release: DesktopReleaseCandidate +): { name: string; browser_download_url: string } | null { + const assets = release.assets ?? [] + return ( + assets.find((asset) => asset.name.endsWith('.dmg')) ?? + assets.find((asset) => asset.name.endsWith('.zip')) ?? + null + ) +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index ee60c365129..e19d7138b6d 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1120, - zodRoutes: 1120, + totalRoutes: 1121, + zodRoutes: 1121, nonZodRoutes: 0, } as const @@ -42,6 +42,9 @@ const INDIRECT_ZOD_ROUTES = new Set([ // Public updater feed: input-less GET, session-less, returns YAML (not JSON), // so it can't be JSON-contract-bound. Wrapped in withRouteHandler. 'apps/sim/app/api/desktop/update/latest-mac.yml/route.ts', + // Public updater download redirect: input-less GET, session-less, whose only + // response is a 302 to a GitHub release asset. Wrapped in withRouteHandler. + 'apps/sim/app/api/desktop/update/download/route.ts', 'apps/sim/app/api/invitations/route.ts', 'apps/sim/app/api/logs/export/route.ts', 'apps/sim/app/api/tools/docusign/route.ts',