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:
OrcaWin
2026-10-11 22:39:47 -07:00
committed by GitHub
co-authored by m4air
parent 4275ee3969
commit ae0ff63152
5 changed files with 142 additions and 38 deletions
@@ -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
View File
@@ -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)
}
+3 -2
View File
@@ -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()
})
})
+60 -35
View File
@@ -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()