From ae0ff6315219fa0492bd2e874954f992bc859694 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 22:39:47 -0700 Subject: [PATCH] fix(serve): initialize the updater in headless Electron serve so paired clients can update it (#27771) Co-authored-by: m4air --- .../startup/main-process-runtime-launch.ts | 8 ++ src/main/updater.ts | 5 +- src/main/updater/updater-setup.ts | 5 +- ...main-window-updater-headless-serve.test.ts | 67 +++++++++++++ src/main/window/main-window-updater.ts | 95 ++++++++++++------- 5 files changed, 142 insertions(+), 38 deletions(-) create mode 100644 src/main/window/main-window-updater-headless-serve.test.ts diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index b8050920f6c..3d81b25c4f3 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -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( diff --git a/src/main/updater.ts b/src/main/updater.ts index 95c457b82c3..945ee7d94cf 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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) } diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts index abfe80bc675..7f800791370 100644 --- a/src/main/updater/updater-setup.ts +++ b/src/main/updater/updater-setup.ts @@ -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 diff --git a/src/main/window/main-window-updater-headless-serve.test.ts b/src/main/window/main-window-updater-headless-serve.test.ts new file mode 100644 index 00000000000..37e330b17d1 --- /dev/null +++ b/src/main/window/main-window-updater-headless-serve.test.ts @@ -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().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() + }) +}) diff --git a/src/main/window/main-window-updater.ts b/src/main/window/main-window-updater.ts index 8719bbe51ad..cf3876aaefa 100644 --- a/src/main/window/main-window-updater.ts +++ b/src/main/window/main-window-updater.ts @@ -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 + 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 - 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()