mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(serve): initialize the updater in headless Electron serve so paired clients can update it (#27771)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -39,6 +39,8 @@ import { triggerStartupNotificationRegistration } from '../ipc/startup-notificat
|
||||
import { startDesktopPushService } from './main-process-push-startup'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
import { logStartupMilestone } from './startup-diagnostics'
|
||||
import { setupHeadlessServeAutoUpdater } from '../window/main-window-updater'
|
||||
import { resolveUpdateInstallMode } from '../updater'
|
||||
import { scheduleAgentLaunchRecordWarmup } from './agent-launch-record-warmup'
|
||||
import { emitServeBrowserIdentityActionLine } from '../server/serve-stdout-boundary'
|
||||
import { getBrowserIdentityModeStatus } from '../browser/browser-identity-mode-store'
|
||||
@@ -214,6 +216,12 @@ async function launchServeMode(
|
||||
void scheduleAllPendingHistoryTreeRemovals()
|
||||
emitServeBrowserIdentityActionLine(getBrowserIdentityModeStatus())
|
||||
await printServeReady(serveOptions)
|
||||
const store = state.store!
|
||||
setupHeadlessServeAutoUpdater(store, {
|
||||
onBeforeUpdateQuit: () => store.flushPendingOrThrowAsync({ fullCheckpoint: true }),
|
||||
onBeforeUpdateQuitFailure: 'abort',
|
||||
updateInstallMode: resolveUpdateInstallMode(true)
|
||||
})
|
||||
}
|
||||
|
||||
async function launchDesktopMode(
|
||||
|
||||
+4
-1
@@ -93,6 +93,9 @@ export function dismissAvailableUpdate(): void {
|
||||
updater.dismissAvailableUpdate()
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void {
|
||||
export function setupAutoUpdater(
|
||||
mainWindow: BrowserWindow | null,
|
||||
opts?: UpdaterSetupOptions
|
||||
): void {
|
||||
updater.setupAutoUpdater(mainWindow, opts)
|
||||
}
|
||||
|
||||
@@ -111,8 +111,9 @@ export class UpdaterSetup extends UpdaterDownloadInstall {
|
||||
super.dismissAvailableUpdate()
|
||||
}
|
||||
|
||||
setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void {
|
||||
this.mainWindowRef = mainWindow
|
||||
setupAutoUpdater(mainWindow: BrowserWindow | null, opts?: UpdaterSetupOptions): void {
|
||||
// Why keep: headless serve sets up without a window; a later window attach must not be undone.
|
||||
this.mainWindowRef = mainWindow ?? this.mainWindowRef
|
||||
this.onBeforeQuitCleanup = opts?.onBeforeQuit ?? null
|
||||
this.onBeforeQuitFailure = opts?.onBeforeQuitFailure ?? 'continue'
|
||||
this.persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type { UpdaterSetupOptions } from '../updater'
|
||||
|
||||
const { setupMock } = vi.hoisted(() => ({
|
||||
setupMock: vi.fn<(window: BrowserWindow | null, options?: UpdaterSetupOptions) => void>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: {}, ipcMain: {} }))
|
||||
vi.mock('../updater', () => ({ setupAutoUpdater: setupMock }))
|
||||
vi.mock('../ipc/ui', () => ({ isTrustedUIRenderer: () => true }))
|
||||
vi.mock('../startup/startup-diagnostics', () => ({ logStartupMilestone: vi.fn() }))
|
||||
|
||||
import { setupHeadlessServeAutoUpdater } from './main-window-updater'
|
||||
|
||||
function createStore() {
|
||||
const store = {
|
||||
getUI: vi.fn(() => ({ lastUpdateCheckAt: 5 })),
|
||||
updateUI: vi.fn(),
|
||||
flushPendingAsync: vi.fn<Store['flushPendingAsync']>().mockResolvedValue(undefined)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: setup only reads getUI/updateUI/flushPendingAsync, all provided above.
|
||||
return { store, persistence: store as unknown as Store }
|
||||
}
|
||||
|
||||
describe('setupHeadlessServeAutoUpdater', () => {
|
||||
afterEach(() => {
|
||||
setupMock.mockReset()
|
||||
})
|
||||
|
||||
it('initializes the updater without a window, in the serve install mode, after readiness', async () => {
|
||||
const { store, persistence } = createStore()
|
||||
const beforeQuit = vi.fn()
|
||||
setupHeadlessServeAutoUpdater(persistence, {
|
||||
onBeforeUpdateQuit: beforeQuit,
|
||||
onBeforeUpdateQuitFailure: 'abort',
|
||||
updateInstallMode: 'supervised-headless-serve'
|
||||
})
|
||||
expect(setupMock).not.toHaveBeenCalled()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(setupMock).toHaveBeenCalledOnce()
|
||||
const [window, options] = setupMock.mock.calls[0] ?? []
|
||||
expect(window).toBeNull()
|
||||
expect(options).toMatchObject({
|
||||
installMode: 'supervised-headless-serve',
|
||||
onBeforeQuitFailure: 'abort'
|
||||
})
|
||||
expect(options?.getLastUpdateCheckAt?.()).toBe(5)
|
||||
await options?.onBeforeQuit?.()
|
||||
expect(beforeQuit).toHaveBeenCalledOnce()
|
||||
expect(store.flushPendingAsync).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('never throws through serve startup when the updater cannot load', async () => {
|
||||
const { persistence } = createStore()
|
||||
setupMock.mockImplementation(() => {
|
||||
throw new Error('electron-updater missing')
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
setupHeadlessServeAutoUpdater(persistence, {})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(warn).toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
setupAutoUpdater,
|
||||
showLinuxPackage,
|
||||
type PreQuitCleanupFailureMode,
|
||||
type UpdateInstallMode
|
||||
type UpdateInstallMode,
|
||||
type UpdaterSetupOptions
|
||||
} from '../updater'
|
||||
|
||||
const UPDATER_SETUP_FALLBACK_MS = 15_000
|
||||
@@ -29,14 +30,66 @@ export function ensureAutoUpdaterConfigured(): void {
|
||||
pendingAutoUpdaterSetup?.()
|
||||
}
|
||||
|
||||
type UpdaterSetupHooks = {
|
||||
onBeforeUpdateQuit?: () => void | Promise<void>
|
||||
onBeforeUpdateQuitFailure?: PreQuitCleanupFailureMode
|
||||
updateInstallMode?: UpdateInstallMode
|
||||
}
|
||||
|
||||
function buildUpdaterSetupOptions(store: Store, options?: UpdaterSetupHooks): UpdaterSetupOptions {
|
||||
return {
|
||||
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,
|
||||
onBeforeQuit: async () => {
|
||||
try {
|
||||
await options?.onBeforeUpdateQuit?.()
|
||||
} finally {
|
||||
await store.flushPendingAsync()
|
||||
}
|
||||
},
|
||||
setLastUpdateCheckAt: (timestamp) => {
|
||||
store.updateUI({ lastUpdateCheckAt: timestamp })
|
||||
},
|
||||
getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null,
|
||||
getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null,
|
||||
setPendingUpdateNudgeId: (id) => {
|
||||
// Why: only the apply branch also nulls dismissedUpdateVersion so relaunch can't resurrect the old hidden card; clearing must not, or it un-dismisses.
|
||||
if (id) {
|
||||
store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null })
|
||||
} else {
|
||||
store.updateUI({ pendingUpdateNudgeId: null })
|
||||
}
|
||||
},
|
||||
setDismissedUpdateNudgeId: (id) => {
|
||||
store.updateUI({ dismissedUpdateNudgeId: id })
|
||||
},
|
||||
getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null,
|
||||
onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure,
|
||||
installMode: options?.updateInstallMode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless serve has no window, so the window-driven setup above never runs and remote clients see
|
||||
* the updater as unavailable forever (#22261). Deferred past readiness: the require is slow.
|
||||
*/
|
||||
export function setupHeadlessServeAutoUpdater(store: Store, options: UpdaterSetupHooks): void {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
setupAutoUpdater(null, buildUpdaterSetupOptions(store, options))
|
||||
logStartupMilestone('updater-setup-done')
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] updater setup failed:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function scheduleMainWindowAutoUpdaterSetup(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
options?: {
|
||||
onBeforeUpdateQuit?: () => void | Promise<void>
|
||||
onBeforeUpdateQuitFailure?: PreQuitCleanupFailureMode
|
||||
updateInstallMode?: UpdateInstallMode
|
||||
}
|
||||
options?: UpdaterSetupHooks
|
||||
): void {
|
||||
// Why: setupAutoUpdater sync-require()s electron-updater (slow on cold Windows w/ Defender, #7225), so defer past first paint; timer fallback covers crash-looping renderers.
|
||||
let updaterSetupDone = false
|
||||
@@ -56,35 +109,7 @@ export function scheduleMainWindowAutoUpdaterSetup(
|
||||
}
|
||||
updaterSetupDone = true
|
||||
try {
|
||||
setupAutoUpdater(mainWindow, {
|
||||
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,
|
||||
onBeforeQuit: async () => {
|
||||
try {
|
||||
await options?.onBeforeUpdateQuit?.()
|
||||
} finally {
|
||||
await store.flushPendingAsync()
|
||||
}
|
||||
},
|
||||
setLastUpdateCheckAt: (timestamp) => {
|
||||
store.updateUI({ lastUpdateCheckAt: timestamp })
|
||||
},
|
||||
getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null,
|
||||
getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null,
|
||||
setPendingUpdateNudgeId: (id) => {
|
||||
// Why: only the apply branch also nulls dismissedUpdateVersion so relaunch can't resurrect the old hidden card; clearing must not, or it un-dismisses.
|
||||
if (id) {
|
||||
store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null })
|
||||
} else {
|
||||
store.updateUI({ pendingUpdateNudgeId: null })
|
||||
}
|
||||
},
|
||||
setDismissedUpdateNudgeId: (id) => {
|
||||
store.updateUI({ dismissedUpdateNudgeId: id })
|
||||
},
|
||||
getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null,
|
||||
onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure,
|
||||
installMode: options?.updateInstallMode
|
||||
})
|
||||
setupAutoUpdater(mainWindow, buildUpdaterSetupOptions(store, options))
|
||||
logStartupMilestone('updater-setup-done')
|
||||
} finally {
|
||||
clearDeferredSetup()
|
||||
|
||||
Reference in New Issue
Block a user