fix(updater): preserve Linux recovery across stale events

This commit is contained in:
Neil
2026-08-31 05:48:39 -07:00
parent 4e74c322c1
commit 4ae8674cd5
4 changed files with 162 additions and 64 deletions
+34 -11
View File
@@ -1,16 +1,48 @@
import type { UpdateStatus } from '../shared/update-status-types'
import {
captureLinuxPackageArtifact,
clearTrackedLinuxPackageArtifact
clearTrackedLinuxPackageArtifact,
getTrackedLinuxPackageArtifact
} from './linux-package-update-recovery'
import {
getLinuxPackageType,
LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE
} from './linux-update-package-type'
import type { LinuxPackageArtifact } from './linux-package-update-recovery'
export const LINUX_PACKAGE_MANUAL_INSTALL_MESSAGE =
'Quit Orca before running the system package install command.'
const PACKAGE_METADATA_UNUSABLE_MESSAGE =
'The downloaded package metadata could not be verified. Quit Orca before downloading and installing the update from the official release page.'
export function createLinuxPackageManualInstallStatus(
artifact: Pick<LinuxPackageArtifact, 'packageType' | 'version'>
): UpdateStatus {
return {
state: 'error',
message: LINUX_PACKAGE_MANUAL_INSTALL_MESSAGE,
recovery: {
kind: 'linux-package-install',
packageType: artifact.packageType,
reason: 'manual-install-required',
version: artifact.version
}
}
}
export function getRetainedLinuxPackageManualInstallStatus(): UpdateStatus | null {
const artifact = getTrackedLinuxPackageArtifact()
return artifact ? createLinuxPackageManualInstallStatus(artifact) : null
}
export function shouldIgnoreDownloadedUpdateEvent(
status: UpdateStatus,
infoVersion: string,
pendingVersion: string
): boolean {
return status.state === 'checking' || (pendingVersion !== '' && infoVersion !== pendingVersion)
}
export function resolveLinuxPackageDownloadedStatus(info: {
version: string
}): UpdateStatus | null {
@@ -36,14 +68,5 @@ export function resolveLinuxPackageDownloadedStatus(info: {
retryable: false
}
}
return {
state: 'error',
message: 'Quit Orca before running the system package install command.',
recovery: {
kind: 'linux-package-install',
packageType: artifact.packageType,
reason: 'manual-install-required',
version: artifact.version
}
}
return createLinuxPackageManualInstallStatus(artifact)
}
+39 -3
View File
@@ -64,7 +64,7 @@ function createContext(overrides?: Partial<HandlerContext>): HandlerContext {
consumeMissingManifestPrereleaseFallbackResult: vi.fn(() => null),
getPublishingWindowLastGoodCheck: vi.fn(() => null),
getMissingManifestPrereleaseFallbackUserInitiated: vi.fn(() => null),
getCurrentStatus: vi.fn(() => ({ state: 'checking' }) as never),
getCurrentStatus: vi.fn(() => ({ state: 'idle' }) as never),
getActiveUpdateCheckEventAttemptId: vi.fn(() => 1),
getKnownReleaseUrl: vi.fn(() => undefined),
getPendingInstallVersion: vi.fn(() => '1.0.61'),
@@ -249,13 +249,37 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => {
expect(context.sendErrorStatus).not.toHaveBeenCalled()
})
it('drops the artifact once the update resolves as not available', async () => {
const { emit, getArtifact } = await register()
it('keeps manual-install recovery when a later check finds no newer release', async () => {
const { emit, context, getArtifact } = await register()
emit('update-downloaded', downloadedEvent())
emit('update-not-available')
expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61' }))
expect(context.sendStatus).toHaveBeenLastCalledWith({
state: 'error',
message: 'Quit Orca before running the system package install command.',
recovery: {
kind: 'linux-package-install',
packageType: 'deb',
reason: 'manual-install-required',
version: '1.0.61'
}
})
})
it('clears recovery when a newer update takes over before no-update settles', async () => {
const { emit, context, getArtifact } = await register()
emit('update-downloaded', downloadedEvent())
emit('update-available', { version: '1.0.62' })
emit('update-not-available')
expect(getArtifact()).toBeNull()
expect(context.sendStatus).toHaveBeenLastCalledWith({
state: 'not-available',
userInitiated: undefined
})
})
it('drops the artifact when another version takes over the cycle', async () => {
@@ -267,6 +291,18 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => {
expect(getArtifact()).toBeNull()
})
it('ignores a downloaded event for an older target', async () => {
const { emit, context, getArtifact } = await register({
getCurrentStatus: vi.fn(() => ({ state: 'available', version: '1.0.62' }) as never),
getPendingInstallVersion: vi.fn(() => '1.0.62')
})
emit('update-downloaded', downloadedEvent())
expect(getArtifact()).toBeNull()
expect(context.sendStatus).not.toHaveBeenCalled()
})
it('drops the artifact when progress reports a different pending version', async () => {
const { emit, getArtifact } = await register({
getPendingInstallVersion: vi.fn(() => '1.0.62')
+31 -49
View File
@@ -1,18 +1,19 @@
import { app, autoUpdater as nativeUpdater } from 'electron'
import { app } from 'electron'
import type { UpdateStatus } from '../shared/update-status-types'
import {
consumeMacInstallGuardBypass,
deferMacQuitUntilInstallerReady,
handleMacInstallerReady,
isMacInstallerReady,
isMacQuitAndInstallInFlight,
registerMacUpdaterEvents,
resetMacInstallState
} from './updater-mac-install'
import { compareVersions } from './updater-fallback'
import { fetchChangelog } from './updater-changelog'
import type { ElectronAutoUpdater } from './electron-updater-loader'
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
import { resolveLinuxPackageDownloadedStatus } from './linux-package-downloaded-status'
import {
getRetainedLinuxPackageManualInstallStatus,
resolveLinuxPackageDownloadedStatus,
shouldIgnoreDownloadedUpdateEvent
} from './linux-package-downloaded-status'
import * as linuxPackageRecovery from './linux-package-update-recovery'
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
@@ -98,47 +99,14 @@ export function registerAutoUpdaterHandlers({
setAvailableVersion,
setUserInitiatedCheck
}: UpdaterHandlerContext): void {
// Why: electron-updater fires 'update-downloaded' before Squirrel.Mac finishes; track readiness to avoid a premature "ready".
if (process.platform === 'darwin') {
nativeUpdater.on('update-downloaded', () => {
const hasInstallableVersion = hasInstallableDownloadedVersion()
handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => {
// Send the held status only while its staged build is still installable.
sendStatus({
state: 'downloaded',
version: getPendingInstallVersion(),
releaseUrl: getKnownReleaseUrl()
})
})
})
}
app.on('before-quit', (event) => {
if (!shouldDeferMacQuitForInstall()) {
return
}
if (consumeMacInstallGuardBypass()) {
recordUpdaterLifecycle('macos_before_quit_guard_bypassed')
return
}
if (isMacQuitAndInstallInFlight()) {
return
}
// Why: quitting before Squirrel.Mac finishes staging leaves nothing to install; hold the quit until it's ready.
if (
deferMacQuitUntilInstallerReady(
getCurrentStatus(),
hasInstallableDownloadedVersion(),
getPendingInstallVersion,
sendStatus
)
) {
recordUpdaterLifecycle('macos_before_quit_deferred', {
version: getPendingInstallVersion()
})
event.preventDefault()
}
registerMacUpdaterEvents({
getCurrentStatus,
hasInstallableDownloadedVersion,
getPendingInstallVersion,
getKnownReleaseUrl,
performQuitAndInstall,
shouldDeferMacQuitForInstall,
sendStatus
})
autoUpdater.on('checking-for-update', () => {
@@ -238,7 +206,7 @@ export function registerAutoUpdaterHandlers({
}
clearBackgroundCheckLaunchPending()
resetMacInstallState()
linuxPackageRecovery.clearTrackedLinuxPackageArtifact()
const retainedStatus = getRetainedLinuxPackageManualInstallStatus()
const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult()
const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck()
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
@@ -259,7 +227,11 @@ export function registerAutoUpdaterHandlers({
}
}
}
sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined })
// Why: a later check can report no newer release while a verified deb/rpm is still waiting for
// the user to install it outside Orca. Keep both the artifact and its recovery card reachable.
sendStatus(
retainedStatus ?? { state: 'not-available', userInitiated: wasUserInitiated || undefined }
)
if (localBuildCheck || pinnedBuildCheck) {
restoreReleaseUpdateSource()
}
@@ -277,6 +249,16 @@ export function registerAutoUpdaterHandlers({
})
autoUpdater.on('update-downloaded', (info) => {
// Why: an earlier download can finish after a newer target replaced it; uncached pre-staged events have no target to compare.
if (
shouldIgnoreDownloadedUpdateEvent(
getCurrentStatus(),
info.version,
getPendingInstallVersion()
)
) {
return
}
clearBackgroundCheckLaunchPending()
// Release downloads remain newer-only; the local source was validated before checking, and a pinned jump is explicit.
if (
+58 -1
View File
@@ -1,9 +1,66 @@
import { app } from 'electron'
import { app, autoUpdater as nativeUpdater } from 'electron'
import type { UpdateStatus } from '../shared/update-status-types'
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
const MAC_INSTALL_READY_TIMEOUT_MS = 15000
export function registerMacUpdaterEvents({
getCurrentStatus,
hasInstallableDownloadedVersion,
getPendingInstallVersion,
getKnownReleaseUrl,
performQuitAndInstall,
shouldDeferMacQuitForInstall,
sendStatus
}: {
getCurrentStatus: () => UpdateStatus
hasInstallableDownloadedVersion: () => boolean
getPendingInstallVersion: () => string
getKnownReleaseUrl: () => string | undefined
performQuitAndInstall: () => void | Promise<void>
shouldDeferMacQuitForInstall: () => boolean
sendStatus: (status: UpdateStatus) => void
}): void {
if (process.platform === 'darwin') {
nativeUpdater.on('update-downloaded', () => {
const hasInstallableVersion = hasInstallableDownloadedVersion()
handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => {
sendStatus({
state: 'downloaded',
version: getPendingInstallVersion(),
releaseUrl: getKnownReleaseUrl()
})
})
})
}
app.on('before-quit', (event) => {
if (!shouldDeferMacQuitForInstall()) {
return
}
if (consumeMacInstallGuardBypass()) {
recordUpdaterLifecycle('macos_before_quit_guard_bypassed')
return
}
if (isMacQuitAndInstallInFlight()) {
return
}
if (
deferMacQuitUntilInstallerReady(
getCurrentStatus(),
hasInstallableDownloadedVersion(),
getPendingInstallVersion,
sendStatus
)
) {
recordUpdaterLifecycle('macos_before_quit_deferred', {
version: getPendingInstallVersion()
})
event.preventDefault()
}
})
}
/** Whether Squirrel.Mac has finished downloading the update from the localhost proxy. */
let squirrelReady = false
/** Remembers a user/app quit request that arrived before Squirrel.Mac had a