diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index 1503a77daf2..f9bc27b93d0 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -99,6 +99,16 @@ const ARTIFACT = { path: '/home/tester/.cache/orca-updater/pending/orca-ide_1.0.61_amd64.deb', sha512: 'LHlL7dKoqg98gS2nfQv878dK+UoktbAkm4M20/hoJ2Qr0Kqsa3MSL4VmWy/Lll/MYjQFkpvOxduQ/vswentozA==' } +const MANUAL_INSTALL_STATUS = { + 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' + } +} as const satisfies UpdateStatus describe('linux package recovery actions', () => { afterEach(() => { @@ -199,6 +209,63 @@ describe('linux package recovery actions', () => { expect(showItemInFolderMock).toHaveBeenCalledTimes(2) }) + it('restores recovery after a recheck resolves without a terminal event', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(1_000) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.showLinuxPackage()).resolves.toBeUndefined() + }) + + it('restores recovery after a recheck fails', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('offline')) + send.mockClear() + + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({ + ok: true, + command: "sudo apt install -- ''", + packageFileName: 'p' + }) + }) + + it('restores recovery when a pinned check resolves to the current version', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.51' }) + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.showLinuxPackage()).resolves.toBeUndefined() + }) + + it('restores recovery when resolving a pinned check fails', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'not-a-release-tag' }) + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({ + ok: true, + command: "sudo apt install -- ''", + packageFileName: 'p' + }) + }) + it('replaces the structured status when revalidation fails so stale actions die', async () => { const { send, updater } = await startUpdater() await activateRecovery(updater) diff --git a/src/main/updater.ts b/src/main/updater.ts index 3982f567dc3..77776eef590 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -39,6 +39,7 @@ import { getLinuxPackageType, LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE } from './linux-update-package-type' +import { getRetainedLinuxPackageManualInstallStatus } from './linux-package-downloaded-status' import { createUpdaterDiagnosticLogger, redactLinuxPackageInstallText @@ -354,6 +355,17 @@ function sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { mainWindowRef?.webContents.send('updater:status', decoratedStatus) } +function sendSettledCheckStatus(status: UpdateStatus): void { + const retainedStatus = getRetainedLinuxPackageManualInstallStatus() + if (retainedStatus) { + sendStatus(retainedStatus) + } else if (status.state === 'error') { + sendErrorStatus(status.message, status.userInitiated) + } else { + sendStatus(status) + } +} + function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { switch (variant) { case 'perf': @@ -571,7 +583,7 @@ function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | und deferPendingUpdateNudgeUntilRetry() return } - sendStatus({ state: 'not-available', userInitiated }) + sendSettledCheckStatus({ state: 'not-available', userInitiated }) } } return @@ -581,7 +593,7 @@ function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | und backgroundCheckPromotedToUserInitiated = false userInitiatedCheck = false completeSilentUpdateCheck(userInitiated) - sendStatus({ state: 'not-available', userInitiated }) + sendSettledCheckStatus({ state: 'not-available', userInitiated }) } function handleSettledUpdateCheckPromise(attemptId: number): void { @@ -994,7 +1006,7 @@ async function sendCheckFailureStatus( // error, or the pin blocks background checks for the process lifetime. clearAvailableUpdateContext() restoreReleaseUpdateSource() - sendStatus({ state: 'error', message, userInitiated }) + sendSettledCheckStatus({ state: 'error', message, userInitiated }) return } const failureKey = getCheckFailureKey(message, userInitiated) @@ -1039,18 +1051,19 @@ async function sendCheckFailureStatus( scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) if (userInitiated) { // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. - sendErrorStatus( - isStableReleaseNotReadyFailure(sourceError) + sendSettledCheckStatus({ + state: 'error', + message: isStableReleaseNotReadyFailure(sourceError) ? "A newer release isn't available for this device yet. Check again later." : "Couldn't reach the update server. Try again in a few minutes.", - true - ) + userInitiated: true + }) } else { if (isRetryableReleaseFeedPreflightFailure(sourceError)) { // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. deferPendingUpdateNudgeUntilRetry() } - sendStatus({ state: 'idle' }) + sendSettledCheckStatus({ state: 'idle' }) } return } @@ -1060,7 +1073,7 @@ async function sendCheckFailureStatus( if (!userInitiated) { scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) } - sendErrorStatus(message, userInitiated) + sendSettledCheckStatus({ state: 'error', message, userInitiated }) } pendingCheckFailureKey = failureKey @@ -1585,7 +1598,7 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { userInitiatedCheck = false finishActiveUpdateCheckAttempt() recordCompletedUpdateCheck() - sendStatus({ state: 'not-available', userInitiated: true }) + sendSettledCheckStatus({ state: 'not-available', userInitiated: true }) return false } return launch() @@ -1712,7 +1725,7 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis try { const target = resolveTargetBuild(channel, tag) if (compareVersions(target.version, app.getVersion()) === 0) { - sendStatus({ state: 'not-available', userInitiated: true }) + sendSettledCheckStatus({ state: 'not-available', userInitiated: true }) return } closeLocalBuildFeed() @@ -1741,7 +1754,7 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis userInitiatedCheck = false clearAvailableUpdateContext() restoreReleaseUpdateSource() - sendStatus({ + sendSettledCheckStatus({ state: 'error', message: String((error as Error)?.message ?? error), userInitiated: true