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',