diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index a8d37301b9d..a0ab479343d 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -4234,6 +4234,7 @@ describe('updater', () => { message: 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' }) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( 'linux_package_revalidation_failed', expect.objectContaining({ action: 'restart-to-install', reason: 'hash-mismatch' }), @@ -4242,7 +4243,7 @@ describe('updater', () => { }) it('installs normally when the retained package still matches its digest', async () => { - const { updater } = await startUpdater('deb') + const { send, updater } = await startUpdater('deb') await reachDownloaded(updater, downloadedEvent()) updater.quitAndInstall() @@ -4250,6 +4251,9 @@ describe('updater', () => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) expect(killAllPtyMock).toHaveBeenCalledTimes(1) + // Why: an abort push here would clear the restart flag mid-quit and re-arm the dirty-buffer + // prompt against the install that is already committed. + expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted') expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( 'linux_package_revalidation_failed', expect.anything(), @@ -4277,6 +4281,9 @@ describe('updater', () => { // The install is still abandoned — only the stale status is withheld. expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) + // Withholding the status must not also withhold the abort: the renderer armed its restart and + // would otherwise skip its unsaved-work prompt for the rest of the session. + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( 'linux_package_revalidation_failed', expect.objectContaining({ reason: 'hash-mismatch' }), diff --git a/src/main/updater.ts b/src/main/updater.ts index a40c3a99b01..302591286f3 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -717,6 +717,10 @@ async function performQuitAndInstall(): Promise { // swapped or vanished package aborts instead of being installed as root. The synchronous guard // keeps every non-Linux install on its existing timing. if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) { + // Why: the renderer armed its restart before invoking, and it infers the abort from the error + // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where + // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + mainWindowRef?.webContents.send('updater:quitAndInstallAborted') return } quitAndInstallInProgress = true diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index 0198c72a44f..6831b5f2181 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -7,10 +7,11 @@ import { } from './renderer-restart-wiring' describe('renderer restart wiring', () => { - it('relays updater status and prevented unload events', () => { + it('relays updater status, aborted installs, and prevented unload events', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() const handleStatus = vi.fn() + const abort = vi.fn() const listeners = new Map void>() const ipcRenderer = { on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { @@ -20,12 +21,15 @@ describe('renderer restart wiring', () => { } as unknown as Parameters[0] eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) - registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus }) + registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus, abort }) listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) + // Why: main abandons an install without any status when its verdict outlived the cycle. + listeners.get('updater:quitAndInstallAborted')?.({}) listeners.get('window:unload-prevented')?.({}) - expect(ipcRenderer.on).toHaveBeenCalledTimes(2) + expect(ipcRenderer.on).toHaveBeenCalledTimes(3) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) + expect(abort).toHaveBeenCalledTimes(1) expect(unloadPrevented).toHaveBeenCalledTimes(1) }) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 254d386403a..8ee6d5f1486 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -13,11 +13,15 @@ import { export function registerRendererRestartIpcRelays( ipcRenderer: Pick, eventTarget: EventTarget, - relay: Pick + relay: Pick ): void { ipcRenderer.on('updater:status', (_event, status: UpdateStatus) => { relay.handleStatus(status) }) + // Why: main abandons some installs without an error status, and only this tells the renderer. + ipcRenderer.on('updater:quitAndInstallAborted', () => { + relay.abort() + }) ipcRenderer.on('window:unload-prevented', () => { eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) })