fix: add updater fallback for GitHub release transitions (#197)

This commit is contained in:
Jinjing 2026-03-29 12:50:36 -07:00 committed by GitHub
parent 2840ff98c1
commit a7ae905a97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 878 additions and 47 deletions

View File

@ -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<FallbackRelease | null> {
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
}

View File

@ -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<string, ((...args: unknown[]) => 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<typeof vi.fn>): unknown[] {
return sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
}
async function expectFallbackAvailable(
sendMock: ReturnType<typeof vi.fn>,
manualDownloadUrl = fallbackAssetUrl
): Promise<void> {
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
})
})
})

View File

@ -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
}
]
}
]
}

View File

@ -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)
})
})

View File

@ -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<void> | 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<void> {
const failureKey = `${userInitiated ? 'user' : 'auto'}:${message}`
if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) {
return pendingCheckFailurePromise
}
const handleFailure = async (): Promise<void> => {
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))

View File

@ -169,7 +169,9 @@ export function GeneralPane({
className="gap-2"
>
<Download className="size-3.5" />
Install Update ({updateStatus.version})
{updateStatus.manualDownloadUrl
? `Download Update (${updateStatus.version})`
: `Install Update (${updateStatus.version})`}
</Button>
) : updateStatus.state === 'downloaded' ? (
<Button
@ -189,10 +191,15 @@ export function GeneralPane({
{updateStatus.state === 'checking' && 'Checking for updates...'}
{updateStatus.state === 'available' && (
<>
Version {updateStatus.version} is available. Click &quot;Install Update&quot; to
download.{' '}
Version {updateStatus.version} is available.{' '}
{updateStatus.manualDownloadUrl
? 'Open the download to install it manually.'
: 'Click "Install Update" to download it.'}{' '}
<a
href={`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`}
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
@ -208,7 +215,10 @@ export function GeneralPane({
<>
Version {updateStatus.version} is ready to install.{' '}
<a
href={`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`}
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"

View File

@ -44,13 +44,18 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
) : (
<span>
Update <span className="font-semibold">v{updateStatus.version}</span> available
Install
{updateStatus.manualDownloadUrl ? ' Download' : ' Install'}
</span>
)}
</button>
{updateVersion && (
<a
href={`https://github.com/stablyai/orca/releases/tag/v${updateVersion}`}
href={
(updateStatus.state === 'available' || updateStatus.state === 'downloaded') &&
updateStatus.releaseUrl
? updateStatus.releaseUrl
: `https://github.com/stablyai/orca/releases/tag/v${updateVersion}`
}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}

View File

@ -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' }

View File

@ -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 ────────────────────────────────────────────────────────