diff --git a/src/main/linux-package-downloaded-status.ts b/src/main/linux-package-downloaded-status.ts index 271b11c1220..b4977ac2b66 100644 --- a/src/main/linux-package-downloaded-status.ts +++ b/src/main/linux-package-downloaded-status.ts @@ -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 +): 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) } diff --git a/src/main/updater-events.test.ts b/src/main/updater-events.test.ts index b4d68c07964..95f0d949256 100644 --- a/src/main/updater-events.test.ts +++ b/src/main/updater-events.test.ts @@ -64,7 +64,7 @@ function createContext(overrides?: Partial): 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') diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 1e35a475b92..c0546abd807 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -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 ( diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index e2441d64180..234cdc0b2c4 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -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 + 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