From a7ae905a970376fc94801db980562252269d072a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:50:36 -0700 Subject: [PATCH] fix: add updater fallback for GitHub release transitions (#197) --- src/main/updater-fallback.ts | 146 ++++++++ src/main/updater.fallback.test.ts | 345 ++++++++++++++++++ src/main/updater.test-fixtures.ts | 55 +++ src/main/updater.test.ts | 178 +++++++-- src/main/updater.ts | 153 +++++++- .../src/components/settings/GeneralPane.tsx | 20 +- .../src/components/sidebar/SidebarToolbar.tsx | 9 +- src/renderer/src/hooks/useIpcEvents.ts | 10 +- src/shared/types.ts | 9 +- 9 files changed, 878 insertions(+), 47 deletions(-) create mode 100644 src/main/updater-fallback.ts create mode 100644 src/main/updater.fallback.test.ts create mode 100644 src/main/updater.test-fixtures.ts diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts new file mode 100644 index 000000000..c25969f87 --- /dev/null +++ b/src/main/updater-fallback.ts @@ -0,0 +1,146 @@ +import { app } from 'electron' + +const RELEASES_API_URL = 'https://api.github.com/repos/stablyai/orca/releases' + +type GitHubReleaseAsset = { + name?: string + browser_download_url?: string +} + +type GitHubRelease = { + draft?: boolean + prerelease?: boolean + tag_name?: string + html_url?: string + assets?: GitHubReleaseAsset[] +} + +export type FallbackRelease = { + version: string + releaseUrl: string + manualDownloadUrl: string +} + +export function isGitHubReleaseTransitionFailure(normalizedMessage: string): boolean { + return ( + normalizedMessage.includes('unable to find latest version on github') || + normalizedMessage.includes('cannot find channel') || + normalizedMessage.includes('latest.yml') || + normalizedMessage.includes('latest-mac.yml') || + normalizedMessage.includes('no published versions on github') + ) +} + +function parseVersion(value: string): number[] | null { + const normalized = value.trim().replace(/^v/i, '') + if (!/^\d+\.\d+\.\d+$/.test(normalized)) { + return null + } + return normalized.split('.').map((part) => Number(part)) +} + +function compareVersions(left: string, right: string): number { + const leftParts = parseVersion(left) + const rightParts = parseVersion(right) + if (!leftParts || !rightParts) { + return 0 + } + + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const leftPart = leftParts[index] ?? 0 + const rightPart = rightParts[index] ?? 0 + if (leftPart !== rightPart) { + return leftPart - rightPart + } + } + + return 0 +} + +function scoreAssetForCurrentPlatform(asset: GitHubReleaseAsset): number { + const assetName = asset.name?.toLowerCase() ?? '' + + if (process.platform === 'darwin') { + const isDmg = assetName.endsWith('.dmg') + const isZip = assetName.endsWith('.zip') + if (!isDmg && !isZip) { + return -1 + } + + const extensionScore = isDmg ? 2 : 1 + const normalizedArch = + process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : null + if (!normalizedArch) { + return extensionScore + } + if (assetName.includes(normalizedArch)) { + return 2 + extensionScore + } + if (assetName.includes('x64') || assetName.includes('arm64')) { + return 0 + } + return extensionScore + } + + if (process.platform === 'win32') { + return assetName.endsWith('.exe') ? 1 : -1 + } + + if (assetName.endsWith('.appimage')) { + return 2 + } + if (assetName.endsWith('.deb')) { + return 1 + } + return -1 +} + +function getManualDownloadAssetUrl(release: GitHubRelease): string | null { + const assets = release.assets ?? [] + const platformAsset = assets + .map((asset) => ({ asset, score: scoreAssetForCurrentPlatform(asset) })) + .filter(({ score }) => score >= 0) + .sort((left, right) => right.score - left.score)[0]?.asset + + return platformAsset?.browser_download_url ?? null +} + +export async function findFallbackReleaseVersion(): Promise { + const response = await fetch(RELEASES_API_URL, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Orca-Updater' + } + }) + + if (!response.ok) { + throw new Error(`GitHub releases lookup failed: ${response.status}`) + } + + const releases = (await response.json()) as GitHubRelease[] + const currentVersion = app.getVersion() + + for (const release of releases) { + if (release.draft || release.prerelease || !release.tag_name || !release.html_url) { + continue + } + + const releaseVersion = release.tag_name.replace(/^v/i, '') + if (compareVersions(releaseVersion, currentVersion) <= 0) { + continue + } + + const manualDownloadUrl = getManualDownloadAssetUrl(release) + if (!manualDownloadUrl) { + continue + } + + return { + version: releaseVersion, + releaseUrl: release.html_url, + manualDownloadUrl + } + } + + return null +} diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts new file mode 100644 index 000000000..ccad5dd79 --- /dev/null +++ b/src/main/updater.fallback.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildReleaseLookupResponse, + getFallbackAssetUrl, + releaseTagUrl +} from './updater.test-fixtures' + +const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, shellMock, isMock } = + vi.hoisted(() => { + const eventHandlers = new Map void)[]>() + + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = eventHandlers.get(event) ?? [] + handlers.push(handler) + eventHandlers.set(event, handlers) + return autoUpdaterMock + }) + + const emit = (event: string, ...args: unknown[]) => { + for (const handler of eventHandlers.get(event) ?? []) { + handler(...args) + } + } + + const reset = () => { + eventHandlers.clear() + on.mockClear() + autoUpdaterMock.checkForUpdates.mockReset() + autoUpdaterMock.downloadUpdate.mockReset() + autoUpdaterMock.quitAndInstall.mockReset() + } + + const autoUpdaterMock = { + autoDownload: false, + autoInstallOnAppQuit: false, + allowPrerelease: false, + on, + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + emit, + reset + } + + return { + appMock: { + isPackaged: true, + getVersion: vi.fn(() => '1.0.51') + }, + browserWindowMock: { + getAllWindows: vi.fn(() => []) + }, + nativeUpdaterMock: { + on: vi.fn() + }, + autoUpdaterMock, + shellMock: { + openExternal: vi.fn() + }, + isMock: { dev: false } + } + }) + +vi.mock('electron', () => ({ + app: appMock, + BrowserWindow: browserWindowMock, + autoUpdater: nativeUpdaterMock, + shell: shellMock +})) + +vi.mock('electron-updater', () => ({ + autoUpdater: autoUpdaterMock +})) + +vi.mock('@electron-toolkit/utils', () => ({ + is: isMock +})) + +vi.mock('./ipc/pty', () => ({ + killAllPty: vi.fn() +})) + +describe('updater fallback', () => { + const fallbackAssetUrl = getFallbackAssetUrl() + const latestMacYmlError = 'Cannot find channel "latest-mac.yml" update info: HttpError: 404' + + function mockReleaseLookupResponse(releases = buildReleaseLookupResponse()): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => releases + })) + ) + } + + function mockCheckFailure(message: string): void { + autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('error', new Error(message)) + }) + return Promise.reject(new Error(message)) + }) + } + + function getStatuses(sendMock: ReturnType): unknown[] { + return sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + } + + async function expectFallbackAvailable( + sendMock: ReturnType, + manualDownloadUrl = fallbackAssetUrl + ): Promise { + await vi.waitFor(() => { + expect(getStatuses(sendMock)).toContainEqual({ + state: 'available', + version: '1.0.61', + releaseUrl: releaseTagUrl, + manualDownloadUrl + }) + }) + } + + beforeEach(() => { + vi.resetModules() + autoUpdaterMock.reset() + nativeUpdaterMock.on.mockReset() + browserWindowMock.getAllWindows.mockReset() + browserWindowMock.getAllWindows.mockReturnValue([]) + shellMock.openExternal.mockReset() + shellMock.openExternal.mockResolvedValue(true) + appMock.getVersion.mockReset() + appMock.getVersion.mockReturnValue('1.0.51') + appMock.isPackaged = true + isMock.dev = false + vi.unstubAllGlobals() + }) + + it('falls back to the latest stable GitHub release when GitHub reports no published versions', async () => { + mockReleaseLookupResponse() + mockCheckFailure('No published versions on GitHub') + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + checkForUpdatesFromMenu() + await expectFallbackAvailable(sendMock) + }) + + it('opens the fallback download URL instead of using electron-updater', async () => { + mockReleaseLookupResponse(buildReleaseLookupResponse().slice(1)) + mockCheckFailure(latestMacYmlError) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu, downloadUpdate } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + checkForUpdatesFromMenu() + await expectFallbackAvailable(sendMock) + + downloadUpdate() + + expect(shellMock.openExternal).toHaveBeenCalledWith(fallbackAssetUrl) + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + }) + + it('preserves the fallback release URL when the update becomes downloaded', async () => { + mockReleaseLookupResponse(buildReleaseLookupResponse().slice(1)) + mockCheckFailure(latestMacYmlError) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('error', new Error(latestMacYmlError)) + + await expectFallbackAvailable(sendMock) + + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + if (process.platform === 'darwin') { + const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find( + ([eventName]) => eventName === 'update-downloaded' + )?.[1] as (() => void) | undefined + expect(nativeDownloadedHandler).toBeTypeOf('function') + nativeDownloadedHandler?.() + } + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'downloaded', + version: '1.0.61', + releaseUrl: releaseTagUrl + }) + }) + }) + + it.runIf(process.platform === 'darwin')( + 'prefers the fallback asset that matches the current macOS architecture', + async () => { + const preferredAssetUrl = + process.arch === 'arm64' + ? 'https://github.com/stablyai/orca/releases/download/v1.0.61/orca-macos-arm64.dmg' + : 'https://github.com/stablyai/orca/releases/download/v1.0.61/orca-macos-x64.dmg' + const nonPreferredAssetUrl = + process.arch === 'arm64' + ? 'https://github.com/stablyai/orca/releases/download/v1.0.61/orca-macos-x64.dmg' + : 'https://github.com/stablyai/orca/releases/download/v1.0.61/orca-macos-arm64.dmg' + + mockReleaseLookupResponse([ + { + draft: false, + prerelease: false, + tag_name: 'v1.0.61', + html_url: releaseTagUrl, + assets: [ + { + name: process.arch === 'arm64' ? 'orca-macos-x64.dmg' : 'orca-macos-arm64.dmg', + browser_download_url: nonPreferredAssetUrl + }, + { + name: process.arch === 'arm64' ? 'orca-macos-arm64.dmg' : 'orca-macos-x64.dmg', + browser_download_url: preferredAssetUrl + } + ] + } + ]) + mockCheckFailure('Unable to find latest version on GitHub') + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + checkForUpdatesFromMenu() + await expectFallbackAvailable(sendMock, preferredAssetUrl) + } + ) + + it.runIf(process.platform === 'darwin')( + 'prefers the dmg over the zip for the matching macOS architecture', + async () => { + const matchingArch = process.arch === 'arm64' ? 'arm64' : 'x64' + const dmgUrl = `https://github.com/stablyai/orca/releases/download/v1.0.61/orca-macos-${matchingArch}.dmg` + const zipUrl = `https://github.com/stablyai/orca/releases/download/v1.0.61/orca-macos-${matchingArch}.zip` + + mockReleaseLookupResponse([ + { + draft: false, + prerelease: false, + tag_name: 'v1.0.61', + html_url: releaseTagUrl, + assets: [ + { + name: `orca-macos-${matchingArch}.zip`, + browser_download_url: zipUrl + }, + { + name: `orca-macos-${matchingArch}.dmg`, + browser_download_url: dmgUrl + } + ] + } + ]) + mockCheckFailure('Unable to find latest version on GitHub') + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + checkForUpdatesFromMenu() + await expectFallbackAvailable(sendMock, dmgUrl) + } + ) + + it('surfaces manual download launcher failures', async () => { + mockReleaseLookupResponse(buildReleaseLookupResponse().slice(1)) + shellMock.openExternal.mockRejectedValueOnce(new Error('launcher blocked')) + mockCheckFailure(latestMacYmlError) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu, downloadUpdate } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + checkForUpdatesFromMenu() + await expectFallbackAvailable(sendMock) + + downloadUpdate() + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: 'launcher blocked', + userInitiated: undefined + }) + }) + }) + + it('clears fallback release metadata before a later check reports no update', async () => { + mockReleaseLookupResponse(buildReleaseLookupResponse().slice(1)) + mockCheckFailure(latestMacYmlError) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + checkForUpdatesFromMenu() + await expectFallbackAvailable(sendMock) + + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-not-available') + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'not-available', + userInitiated: undefined + }) + }) + + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + + expect(sendMock).not.toHaveBeenCalledWith('updater:status', { + state: 'downloaded', + version: '1.0.61', + releaseUrl: releaseTagUrl + }) + }) +}) diff --git a/src/main/updater.test-fixtures.ts b/src/main/updater.test-fixtures.ts new file mode 100644 index 000000000..8b480908e --- /dev/null +++ b/src/main/updater.test-fixtures.ts @@ -0,0 +1,55 @@ +export const releaseTagUrl = 'https://github.com/stablyai/orca/releases/tag/v1.0.61' + +export const releaseDownloadUrls = { + darwin: 'https://github.com/stablyai/orca/releases/download/v1.0.61/orca-macos-arm64.dmg', + linux: 'https://github.com/stablyai/orca/releases/download/v1.0.61/orca-linux.AppImage', + win32: 'https://github.com/stablyai/orca/releases/download/v1.0.61/orca-windows-setup.exe' +} as const + +export function getFallbackAssetUrl(): string { + if (process.platform === 'darwin') { + return releaseDownloadUrls.darwin + } + if (process.platform === 'win32') { + return releaseDownloadUrls.win32 + } + return releaseDownloadUrls.linux +} + +export function buildReleaseLookupResponse(): { + draft: boolean + prerelease: boolean + tag_name: string + html_url: string + assets: { name: string; browser_download_url: string }[] +}[] { + return [ + { + draft: false, + prerelease: false, + tag_name: 'v1.0.62', + html_url: 'https://github.com/stablyai/orca/releases/tag/v1.0.62', + assets: [] + }, + { + draft: false, + prerelease: false, + tag_name: 'v1.0.61', + html_url: releaseTagUrl, + assets: [ + { + name: 'orca-macos-arm64.dmg', + browser_download_url: releaseDownloadUrls.darwin + }, + { + name: 'orca-windows-setup.exe', + browser_download_url: releaseDownloadUrls.win32 + }, + { + name: 'orca-linux.AppImage', + browser_download_url: releaseDownloadUrls.linux + } + ] + } + ] +} diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index acb1b5170..85cc25082 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildReleaseLookupResponse, + getFallbackAssetUrl, + releaseTagUrl +} from './updater.test-fixtures' const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, + shellMock, isMock, killAllPtyMock } = vi.hoisted(() => { @@ -55,6 +61,9 @@ const { on: vi.fn() }, autoUpdaterMock, + shellMock: { + openExternal: vi.fn() + }, isMock: { dev: false }, killAllPtyMock: vi.fn() } @@ -63,7 +72,8 @@ const { vi.mock('electron', () => ({ app: appMock, BrowserWindow: browserWindowMock, - autoUpdater: nativeUpdaterMock + autoUpdater: nativeUpdaterMock, + shell: shellMock })) vi.mock('electron-updater', () => ({ @@ -79,29 +89,30 @@ vi.mock('./ipc/pty', () => ({ })) describe('updater', () => { + const fallbackAssetUrl = getFallbackAssetUrl() beforeEach(() => { vi.resetModules() autoUpdaterMock.reset() nativeUpdaterMock.on.mockReset() browserWindowMock.getAllWindows.mockReset() browserWindowMock.getAllWindows.mockReturnValue([]) + shellMock.openExternal.mockReset() appMock.getVersion.mockReset() appMock.getVersion.mockReturnValue('1.0.51') appMock.isPackaged = true isMock.dev = false killAllPtyMock.mockReset() + vi.unstubAllGlobals() }) it('deduplicates identical check errors from the event and rejected promise', async () => { - autoUpdaterMock.checkForUpdates - .mockResolvedValueOnce(undefined) - .mockImplementationOnce(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => { - autoUpdaterMock.emit('error', new Error('boom')) - }) - return Promise.reject(new Error('boom')) + autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('error', new Error('boom')) }) + return Promise.reject(new Error('boom')) + }) const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } @@ -110,8 +121,12 @@ describe('updater', () => { setupAutoUpdater(mainWindow as never) checkForUpdatesFromMenu() - await Promise.resolve() - await Promise.resolve() + await vi.waitFor(() => { + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + expect(statuses).toContainEqual({ state: 'error', message: 'boom', userInitiated: true }) + }) const errorStatuses = sendMock.mock.calls .filter(([channel]) => channel === 'updater:status') @@ -122,15 +137,13 @@ describe('updater', () => { }) it('treats net::ERR_FAILED during checks as a benign idle transition', async () => { - autoUpdaterMock.checkForUpdates - .mockResolvedValueOnce(undefined) - .mockImplementationOnce(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => { - autoUpdaterMock.emit('error', new Error('net::ERR_FAILED')) - }) - return Promise.reject(new Error('net::ERR_FAILED')) + autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('error', new Error('net::ERR_FAILED')) }) + return Promise.reject(new Error('net::ERR_FAILED')) + }) const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } @@ -139,8 +152,12 @@ describe('updater', () => { setupAutoUpdater(mainWindow as never) checkForUpdatesFromMenu() - await Promise.resolve() - await Promise.resolve() + await vi.waitFor(() => { + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + expect(statuses).toContainEqual({ state: 'idle' }) + }) const statuses = sendMock.mock.calls .filter(([channel]) => channel === 'updater:status') @@ -152,4 +169,123 @@ describe('updater', () => { expect.objectContaining({ state: 'error', message: 'net::ERR_FAILED' }) ) }) + + it('treats GitHub release transition errors during checks as benign', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => buildReleaseLookupResponse() + })) + ) + + autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub')) + }) + return Promise.reject(new Error('Unable to find latest version on GitHub')) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + checkForUpdatesFromMenu() + await vi.waitFor(() => { + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + expect(statuses).toContainEqual({ + state: 'available', + version: '1.0.61', + releaseUrl: releaseTagUrl, + manualDownloadUrl: fallbackAssetUrl + }) + }) + + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + + expect(statuses).toContainEqual({ state: 'checking', userInitiated: true }) + expect(statuses).toContainEqual({ + state: 'available', + version: '1.0.61', + releaseUrl: releaseTagUrl, + manualDownloadUrl: fallbackAssetUrl + }) + expect(statuses).not.toContainEqual( + expect.objectContaining({ + state: 'error', + message: 'Unable to find latest version on GitHub' + }) + ) + expect( + statuses.filter( + (status) => + typeof status === 'object' && + status !== null && + status.state === 'available' && + status.version === '1.0.61' + ) + ).toHaveLength(1) + }) + + it('treats missing latest-mac.yml during checks as a benign idle transition', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => [] + })) + ) + + autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit( + 'error', + new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404') + ) + }) + return Promise.reject( + new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404') + ) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never) + checkForUpdatesFromMenu() + await vi.waitFor(() => { + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + expect(statuses).toContainEqual({ state: 'idle' }) + }) + + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + + expect(statuses).toContainEqual({ state: 'checking', userInitiated: true }) + expect(statuses).toContainEqual({ state: 'idle' }) + expect(statuses).not.toContainEqual( + expect.objectContaining({ + state: 'error', + message: 'Cannot find channel "latest-mac.yml" update info: HttpError: 404' + }) + ) + expect( + statuses.filter( + (status) => typeof status === 'object' && status !== null && status.state === 'idle' + ) + ).toHaveLength(1) + }) }) diff --git a/src/main/updater.ts b/src/main/updater.ts index 62da220b6..928b2d19d 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,8 +1,9 @@ -import { app, BrowserWindow, autoUpdater as nativeUpdater } from 'electron' +import { app, BrowserWindow, autoUpdater as nativeUpdater, shell } from 'electron' import { autoUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' import type { UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' +import { findFallbackReleaseVersion, isGitHubReleaseTransitionFailure } from './updater-fallback' let mainWindowRef: BrowserWindow | null = null let currentStatus: UpdateStatus = { state: 'idle' } @@ -10,10 +11,57 @@ let userInitiatedCheck = false let onBeforeQuitCleanup: (() => void) | null = null let autoUpdaterInitialized = false let availableVersion: string | null = null +let availableReleaseUrl: string | null = null +let pendingCheckFailureKey: string | null = null +let pendingCheckFailurePromise: Promise | null = null /** Whether Squirrel.Mac has finished downloading the update from the localhost proxy. */ let squirrelReady = false +function clearAvailableUpdateContext(): void { + availableVersion = null + availableReleaseUrl = null +} + +function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean { + switch (left.state) { + case 'idle': + return right.state === 'idle' + case 'checking': + return right.state === 'checking' && left.userInitiated === right.userInitiated + case 'not-available': + return right.state === 'not-available' && left.userInitiated === right.userInitiated + case 'available': + return ( + right.state === 'available' && + left.version === right.version && + left.releaseUrl === right.releaseUrl && + left.manualDownloadUrl === right.manualDownloadUrl + ) + case 'downloading': + return ( + right.state === 'downloading' && + left.version === right.version && + left.percent === right.percent + ) + case 'downloaded': + return ( + right.state === 'downloaded' && + left.version === right.version && + left.releaseUrl === right.releaseUrl + ) + case 'error': + return ( + right.state === 'error' && + left.message === right.message && + left.userInitiated === right.userInitiated + ) + } +} + function sendStatus(status: UpdateStatus): void { + if (statusesEqual(currentStatus, status)) { + return + } currentStatus = status mainWindowRef?.webContents.send('updater:status', status) } @@ -29,17 +77,79 @@ function sendErrorStatus(message: string, userInitiated?: boolean): void { sendStatus({ state: 'error', message, userInitiated }) } -function isBenignCheckFailure(message: string): boolean { - return message.includes('net::ERR_FAILED') +function getKnownReleaseUrl(): string | undefined { + return availableReleaseUrl ?? undefined } -function sendCheckFailureStatus(message: string, userInitiated?: boolean): void { - if (isBenignCheckFailure(message)) { - console.warn('[updater] benign check failure:', message) - sendStatus({ state: 'idle' }) - return +function isBenignCheckFailure(message: string): boolean { + const normalizedMessage = message.toLowerCase() + + if (normalizedMessage.includes('net::err_failed')) { + return true } - sendErrorStatus(message, userInitiated) + + // GitHub releases can briefly be in a half-published state while the + // release workflow is creating a draft and uploading update metadata. + // During that window electron-updater may fail the check even though + // nothing is wrong on the client side. + return ( + isGitHubReleaseTransitionFailure(normalizedMessage) || + normalizedMessage.includes('no published versions on github') + ) +} + +async function sendCheckFailureStatus(message: string, userInitiated?: boolean): Promise { + const failureKey = `${userInitiated ? 'user' : 'auto'}:${message}` + if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) { + return pendingCheckFailurePromise + } + + const handleFailure = async (): Promise => { + if (isBenignCheckFailure(message)) { + if (isGitHubReleaseTransitionFailure(message.toLowerCase())) { + try { + const fallbackRelease = await findFallbackReleaseVersion() + if (fallbackRelease) { + console.warn( + '[updater] using fallback GitHub release during release transition:', + fallbackRelease.version + ) + availableVersion = fallbackRelease.version + availableReleaseUrl = fallbackRelease.releaseUrl + sendStatus({ + state: 'available', + version: fallbackRelease.version, + releaseUrl: fallbackRelease.releaseUrl, + manualDownloadUrl: fallbackRelease.manualDownloadUrl + }) + return + } + } catch (fallbackError) { + console.warn( + '[updater] fallback GitHub release lookup failed:', + String((fallbackError as Error)?.message ?? fallbackError) + ) + } + } + + console.warn('[updater] benign check failure:', message) + clearAvailableUpdateContext() + sendStatus({ state: 'idle' }) + return + } + + clearAvailableUpdateContext() + sendErrorStatus(message, userInitiated) + } + + pendingCheckFailureKey = failureKey + pendingCheckFailurePromise = handleFailure().finally(() => { + if (pendingCheckFailureKey === failureKey) { + pendingCheckFailureKey = null + pendingCheckFailurePromise = null + } + }) + return pendingCheckFailurePromise } export function getUpdateStatus(): UpdateStatus { @@ -54,7 +164,7 @@ export function checkForUpdates(): void { // Don't send 'checking' here — the 'checking-for-update' event handler does it, // and sending it from both places causes duplicate notifications (issue #35). autoUpdater.checkForUpdates().catch((err) => { - sendCheckFailureStatus(String(err?.message ?? err)) + void sendCheckFailureStatus(String(err?.message ?? err)) }) } @@ -71,7 +181,7 @@ export function checkForUpdatesFromMenu(): void { autoUpdater.checkForUpdates().catch((err) => { userInitiatedCheck = false - sendCheckFailureStatus(String(err?.message ?? err), true) + void sendCheckFailureStatus(String(err?.message ?? err), true) }) } @@ -127,12 +237,17 @@ export function setupAutoUpdater( squirrelReady = true // If we were holding the 'downloaded' status, send it now if (availableVersion && availableVersion !== app.getVersion()) { - sendStatus({ state: 'downloaded', version: availableVersion }) + sendStatus({ + state: 'downloaded', + version: availableVersion, + releaseUrl: getKnownReleaseUrl() + }) } }) } autoUpdater.on('checking-for-update', () => { + clearAvailableUpdateContext() sendStatus({ state: 'checking', userInitiated: userInitiatedCheck || undefined }) }) @@ -143,16 +258,19 @@ export function setupAutoUpdater( // With allowPrerelease enabled, electron-updater may consider the // current version as an "available" update (same-version match). if (info.version === app.getVersion()) { + clearAvailableUpdateContext() sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) return } availableVersion = info.version + availableReleaseUrl = null sendStatus({ state: 'available', version: info.version }) }) autoUpdater.on('update-not-available', () => { const wasUserInitiated = userInitiatedCheck userInitiatedCheck = false + clearAvailableUpdateContext() sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) }) @@ -167,6 +285,7 @@ export function setupAutoUpdater( autoUpdater.on('update-downloaded', (info) => { // Don't show the banner if the downloaded version is the one already running. if (info.version === app.getVersion()) { + clearAvailableUpdateContext() sendStatus({ state: 'not-available' }) return } @@ -180,7 +299,7 @@ export function setupAutoUpdater( sendStatus({ state: 'downloading', percent: 100, version: info.version }) return } - sendStatus({ state: 'downloaded', version: info.version }) + sendStatus({ state: 'downloaded', version: info.version, releaseUrl: getKnownReleaseUrl() }) }) autoUpdater.on('error', (err) => { @@ -188,7 +307,7 @@ export function setupAutoUpdater( userInitiatedCheck = false const message = err?.message ?? 'Unknown error' if (currentStatus.state === 'checking') { - sendCheckFailureStatus(message, wasUserInitiated || undefined) + void sendCheckFailureStatus(message, wasUserInitiated || undefined) return } sendErrorStatus(message, wasUserInitiated || undefined) @@ -204,6 +323,12 @@ export function downloadUpdate(): void { if (currentStatus.state !== 'available') { return } + if (currentStatus.manualDownloadUrl) { + shell.openExternal(currentStatus.manualDownloadUrl).catch((err) => { + sendErrorStatus(String(err?.message ?? err)) + }) + return + } squirrelReady = false autoUpdater.downloadUpdate().catch((err) => { sendErrorStatus(String(err?.message ?? err)) diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 678d4be85..b3214ed72 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -169,7 +169,9 @@ export function GeneralPane({ className="gap-2" > - Install Update ({updateStatus.version}) + {updateStatus.manualDownloadUrl + ? `Download Update (${updateStatus.version})` + : `Install Update (${updateStatus.version})`} ) : updateStatus.state === 'downloaded' ? ( {updateVersion && ( e.stopPropagation()} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 36c64e3cf..d86b02082 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -63,7 +63,9 @@ export function useIpcEvents(): void { description: createElement( 'a', { - href: `https://github.com/stablyai/orca/releases/tag/v${status.version}`, + href: + status.releaseUrl ?? + `https://github.com/stablyai/orca/releases/tag/v${status.version}`, target: '_blank', rel: 'noopener noreferrer', style: { textDecoration: 'underline' } @@ -72,7 +74,7 @@ export function useIpcEvents(): void { ), duration: Infinity, action: { - label: 'Install', + label: status.manualDownloadUrl ? 'Download' : 'Install', onClick: () => window.api.updater.download() } }) @@ -95,7 +97,9 @@ export function useIpcEvents(): void { description: createElement( 'a', { - href: `https://github.com/stablyai/orca/releases/tag/v${status.version}`, + href: + status.releaseUrl ?? + `https://github.com/stablyai/orca/releases/tag/v${status.version}`, target: '_blank', rel: 'noopener noreferrer', style: { textDecoration: 'underline' } diff --git a/src/shared/types.ts b/src/shared/types.ts index 9dc47f689..e7b0823b3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -145,10 +145,15 @@ export type RepoHookSettings = { export type UpdateStatus = | { state: 'idle' } | { state: 'checking'; userInitiated?: boolean } - | { state: 'available'; version: string } + | { + state: 'available' + version: string + releaseUrl?: string + manualDownloadUrl?: string + } | { state: 'not-available'; userInitiated?: boolean } | { state: 'downloading'; percent: number; version: string } - | { state: 'downloaded'; version: string } + | { state: 'downloaded'; version: string; releaseUrl?: string } | { state: 'error'; message: string; userInitiated?: boolean } // ─── Settings ────────────────────────────────────────────────────────