fix: add updater fallback for GitHub release transitions (#197)
This commit is contained in:
parent
2840ff98c1
commit
a7ae905a97
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 "Install Update" 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"
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue