From 7b5b57523bd697cf24a140165f58407da8ec4385 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 28 May 2026 21:19:43 -0700 Subject: [PATCH] Implement background update downloads Co-authored-by: Orca --- src/main/updater-events.ts | 49 +++++ src/main/updater.check-failure.test.ts | 2 +- src/main/updater.mac-install.test.ts | 2 +- src/main/updater.test.ts | 192 +++++++++++++++++- src/main/updater.ts | 42 +++- .../src/components/UpdateCard.test.ts | 118 ++++++++++- src/renderer/src/components/UpdateCard.tsx | 24 +++ src/shared/types.ts | 8 +- 8 files changed, 423 insertions(+), 14 deletions(-) diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 5b979b3bbfe..8e66d2d164f 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -19,15 +19,18 @@ type UpdaterHandlerContext = { autoUpdater: ElectronAutoUpdater clearBackgroundCheckLaunchPending: () => void clearAvailableUpdateContext: () => void + clearStagedUpdateContext: () => void consumeMissingManifestPrereleaseFallbackResult: () => { userInitiated: boolean } | null getPublishingWindowLastGoodCheck: () => { lastGoodTag: string } | null getMissingManifestPrereleaseFallbackUserInitiated: () => boolean | null getCurrentStatus: () => UpdateStatus getKnownReleaseUrl: () => string | undefined getPendingInstallVersion: () => string + getStagedUpdateVersion: () => string | null getUserInitiatedCheck: () => boolean hasNewerDownloadedVersion: () => boolean markMissingManifestPrereleaseFallbackChecking: () => void + markStagedUpdate: (version: string, releaseUrl?: string) => void performQuitAndInstall: () => void recordCompletedUpdateCheck: () => void sendCheckFailureStatus: ( @@ -39,6 +42,7 @@ type UpdaterHandlerContext = { sendErrorStatus: (message: string, userInitiated?: boolean) => void sendStatus: (status: UpdateStatus) => void scheduleAutomaticUpdateCheck: (delayMs: number) => void + startAvailableUpdateDownload: () => void shouldSuppressMissingManifestPrereleaseFallbackEvent: (message: string, error: unknown) => boolean suppressMissingManifestPrereleaseFallbackPromiseFailure: (message: string) => void setAvailableReleaseUrl: (releaseUrl: string | null) => void @@ -50,21 +54,25 @@ export function registerAutoUpdaterHandlers({ autoUpdater, clearBackgroundCheckLaunchPending, clearAvailableUpdateContext, + clearStagedUpdateContext, consumeMissingManifestPrereleaseFallbackResult, getPublishingWindowLastGoodCheck, getMissingManifestPrereleaseFallbackUserInitiated, getCurrentStatus, getKnownReleaseUrl, getPendingInstallVersion, + getStagedUpdateVersion, getUserInitiatedCheck, hasNewerDownloadedVersion, markMissingManifestPrereleaseFallbackChecking, + markStagedUpdate, performQuitAndInstall, recordCompletedUpdateCheck, sendCheckFailureStatus, sendErrorStatus, sendStatus, scheduleAutomaticUpdateCheck, + startAvailableUpdateDownload, shouldSuppressMissingManifestPrereleaseFallbackEvent, suppressMissingManifestPrereleaseFallbackPromiseFailure, setAvailableReleaseUrl, @@ -146,6 +154,32 @@ export function registerAutoUpdaterHandlers({ return } + const stagedVersion = getStagedUpdateVersion() + if (stagedVersion && compareVersions(info.version, stagedVersion) <= 0) { + // Why: a freshness check should offer an already-staged update only when + // the feed does not point at a newer version. + clearAvailableUpdateContext() + if (missingManifestFallback || publishingWindowLastGoodCheck) { + scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } else { + recordCompletedUpdateCheck() + if (!wasUserInitiated) { + scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + } + sendStatus({ + state: 'downloaded', + version: stagedVersion, + releaseUrl: getKnownReleaseUrl(), + userInitiated: wasUserInitiated || undefined + }) + return + } + + // A newer feed result supersedes the staged installer; don't keep offering + // restart for an update we now know is stale. + clearStagedUpdateContext() + // Why: fetching changelog in the main process avoids CORS issues that // would block a renderer-side fetch to onorca.dev, and ensures the // card can render immediately without an async loading gap. @@ -182,6 +216,7 @@ export function registerAutoUpdaterHandlers({ } sendStatus({ state: 'available', version: info.version, changelog }) + startAvailableUpdateDownload() })() }) @@ -204,6 +239,18 @@ export function registerAutoUpdaterHandlers({ scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) } } + const stagedVersion = getStagedUpdateVersion() + if (stagedVersion && compareVersions(stagedVersion, app.getVersion()) > 0) { + // Why: "not available" means no newer feed result, not that a previously + // staged update disappeared. Keep the restart action available. + sendStatus({ + state: 'downloaded', + version: stagedVersion, + releaseUrl: getKnownReleaseUrl(), + userInitiated: wasUserInitiated || undefined + }) + return + } sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) }) @@ -223,9 +270,11 @@ export function registerAutoUpdaterHandlers({ // as stale cached updates from an older release. if (compareVersions(info.version, app.getVersion()) <= 0) { clearAvailableUpdateContext() + clearStagedUpdateContext() sendStatus({ state: 'not-available' }) return } + markStagedUpdate(info.version, getKnownReleaseUrl()) // On macOS, defer the 'downloaded' status until Squirrel.Mac has finished // processing the update via the localhost proxy. On other platforms, // the update is ready immediately after electron-updater downloads it. diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 2ecf0c855be..dc2126f93a3 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -31,7 +31,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset() - autoUpdaterMock.downloadUpdate.mockReset() + autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([]) autoUpdaterMock.quitAndInstall.mockReset() autoUpdaterMock.setFeedURL.mockClear() } diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index c4baf8cecdb..8cf199ac214 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -44,7 +44,7 @@ const { eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset() - autoUpdaterMock.downloadUpdate.mockReset() + autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([]) autoUpdaterMock.quitAndInstall.mockReset() } diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index a50ef2c3abe..b2cc9a842ea 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -45,7 +45,7 @@ const { eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null) - autoUpdaterMock.downloadUpdate.mockReset() + autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([]) autoUpdaterMock.quitAndInstall.mockReset() autoUpdaterMock.setFeedURL.mockClear() autoUpdaterMock.updateConfigPath = undefined @@ -721,6 +721,196 @@ describe('updater', () => { }) }) + it('starts downloading automatically after an available update is announced', async () => { + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + }) + return Promise.resolve(undefined) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null }) + + await vi.waitFor(() => { + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + }) + + expect(autoUpdaterMock.autoDownload).toBe(false) + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.0.61', + changelog: null + }) + + const availableStatusCallIndex = sendMock.mock.calls.findIndex( + ([channel, status]) => + channel === 'updater:status' && + typeof status === 'object' && + status !== null && + status.state === 'available' + ) + expect(availableStatusCallIndex).not.toBe(-1) + expect(sendMock.mock.invocationCallOrder[availableStatusCallIndex]).toBeLessThan( + autoUpdaterMock.downloadUpdate.mock.invocationCallOrder[0] + ) + }) + + it('deduplicates manual download clicks while the automatic download is starting', async () => { + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + }) + return Promise.resolve(undefined) + }) + autoUpdaterMock.downloadUpdate.mockImplementation(() => new Promise(() => {})) + + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater, downloadUpdate } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null }) + + await vi.waitFor(() => { + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + }) + + downloadUpdate() + + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + }) + + it('surfaces automatic download failures against the cached update version', async () => { + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + }) + return Promise.resolve(undefined) + }) + autoUpdaterMock.downloadUpdate.mockRejectedValue(new Error('disk full')) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: 'disk full' + }) + }) + }) + + it('shows a staged update after a user check confirms nothing newer is available', async () => { + vi.stubGlobal('process', { ...process, platform: 'linux' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-not-available') + }) + return Promise.resolve(undefined) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + sendMock.mockClear() + + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'downloaded', + version: '1.0.61', + releaseUrl: undefined, + userInitiated: true + }) + }) + }) + + it('does not redownload when a user check finds the already-staged version', async () => { + vi.stubGlobal('process', { ...process, platform: 'linux' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + }) + return Promise.resolve(undefined) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + autoUpdaterMock.downloadUpdate.mockClear() + sendMock.mockClear() + + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'downloaded', + version: '1.0.61', + releaseUrl: undefined, + userInitiated: true + }) + }) + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + }) + + it('downloads the newer feed version when a staged update is stale', async () => { + vi.stubGlobal('process', { ...process, platform: 'linux' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-available', { version: '1.0.62' }) + }) + return Promise.resolve(undefined) + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + autoUpdaterMock.downloadUpdate.mockClear() + sendMock.mockClear() + + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.0.62', + changelog: null + }) + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) + }) + expect(sendMock).not.toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'downloaded', version: '1.0.61' }) + ) + }) + it('does not leak a nudge marker into a later ordinary update cycle', async () => { const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } diff --git a/src/main/updater.ts b/src/main/updater.ts index 0359fc3898b..b6999f1d64b 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -47,6 +47,8 @@ let autoUpdaterInitialized = false let includePrereleaseActive = false let availableVersion: string | null = null let availableReleaseUrl: string | null = null +let stagedUpdateVersion: string | null = null +let stagedUpdateReleaseUrl: string | null = null let pendingCheckFailureKey: string | null = null let pendingCheckFailurePromise: Promise | null = null let autoUpdateCheckTimer: ReturnType | null = null @@ -103,6 +105,20 @@ function clearAvailableUpdateContext(): void { availableReleaseUrl = null } +function clearStagedUpdateContext(): void { + stagedUpdateVersion = null + stagedUpdateReleaseUrl = null +} + +function getStagedUpdateVersion(): string | null { + return stagedUpdateVersion +} + +function markStagedUpdate(version: string, releaseUrl?: string): void { + stagedUpdateVersion = version + stagedUpdateReleaseUrl = releaseUrl ?? null +} + function clearPrereleaseFallbackContext(): void { pendingPrereleaseFallback = null } @@ -151,9 +167,9 @@ function sendStatus(status: UpdateStatus): void { status.state === 'available' || status.state === 'error') if (awaitingNudgeCheckOutcome) { - if (status.state === 'available') { + if (status.state === 'available' || status.state === 'downloaded') { if (shouldPreserveNudgeForPublishingWindow) { - // Why: a last-good available update is only a temporary fallback; don't + // Why: a last-good ready update is only a temporary fallback; don't // let dismissing that card consume the newest-release nudge campaign. deferPendingUpdateNudgeUntilRetry() } else { @@ -189,6 +205,7 @@ function sendStatus(status: UpdateStatus): void { status.state === 'idle' || status.state === 'not-available' || status.state === 'available' || + status.state === 'downloaded' || status.state === 'error' ) { clearPublishingWindowLastGoodCheck() @@ -198,6 +215,7 @@ function sendStatus(status: UpdateStatus): void { // window where duplicate download() calls are possible. if ( decoratedStatus.state === 'downloading' || + decoratedStatus.state === 'downloaded' || decoratedStatus.state === 'error' || decoratedStatus.state === 'idle' ) { @@ -226,17 +244,21 @@ function sendErrorStatus(message: string, userInitiated?: boolean): void { } function getKnownReleaseUrl(): string | undefined { - return availableReleaseUrl ?? undefined + return availableReleaseUrl ?? stagedUpdateReleaseUrl ?? undefined } function hasNewerDownloadedVersion(): boolean { - return availableVersion !== null && compareVersions(availableVersion, app.getVersion()) > 0 + const version = stagedUpdateVersion ?? availableVersion + return version !== null && compareVersions(version, app.getVersion()) > 0 } function getPendingInstallVersion(): string { if (availableVersion) { return availableVersion } + if (stagedUpdateVersion) { + return stagedUpdateVersion + } if (currentStatus.state === 'downloading' || currentStatus.state === 'downloaded') { return currentStatus.version } @@ -831,6 +853,8 @@ export function setupAutoUpdater( } const autoUpdater = getAutoUpdater() + // Why: Orca fetches changelog and pins release context before downloading; + // letting electron-updater auto-start can race progress events ahead of that. autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true @@ -886,23 +910,27 @@ export function setupAutoUpdater( registerAutoUpdaterHandlers({ autoUpdater, clearAvailableUpdateContext, + clearStagedUpdateContext, consumeMissingManifestPrereleaseFallbackResult, getMissingManifestPrereleaseFallbackUserInitiated, getPublishingWindowLastGoodCheck, getCurrentStatus: () => currentStatus, getKnownReleaseUrl, getPendingInstallVersion, + getStagedUpdateVersion, getUserInitiatedCheck: () => userInitiatedCheck, hasNewerDownloadedVersion, performQuitAndInstall, sendCheckFailureStatus, sendErrorStatus, markMissingManifestPrereleaseFallbackChecking, + markStagedUpdate, shouldSuppressMissingManifestPrereleaseFallbackEvent, suppressMissingManifestPrereleaseFallbackPromiseFailure, recordCompletedUpdateCheck, sendStatus, scheduleAutomaticUpdateCheck, + startAvailableUpdateDownload, clearBackgroundCheckLaunchPending, setAvailableReleaseUrl: (releaseUrl) => { availableReleaseUrl = releaseUrl @@ -950,7 +978,7 @@ export function setupAutoUpdater( } } -export function downloadUpdate(): void { +function startAvailableUpdateDownload(): void { if (downloadInFlight) { return } @@ -973,3 +1001,7 @@ export function downloadUpdate(): void { sendErrorStatus(String(err?.message ?? err)) }) } + +export function downloadUpdate(): void { + startAvailableUpdateDownload() +} diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index d69a8436bab..3efef34e28a 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -293,14 +293,22 @@ type VisibilityInput = { dismissedVersion: string | null cachedVersion: string | null hasStartedDownload: boolean + userInitiatedCycle?: boolean } type VisibilityResult = 'hidden' | 'visible' /** Mirrors the visibility gates in UpdateCard's render path. */ function computeVisibility(input: VisibilityInput): VisibilityResult { - const { status, dismissedVersion, cachedVersion, hasStartedDownload } = input + const { + status, + dismissedVersion, + cachedVersion, + hasStartedDownload, + userInitiatedCycle = false + } = input const isUserInitiated = 'userInitiated' in status && status.userInitiated + const isNudgeDriven = 'activeNudgeId' in status && Boolean(status.activeNudgeId) const shouldShowDetailedErrorCard = status.state === 'error' && (hasStartedDownload || cachedVersion !== null) @@ -313,6 +321,26 @@ function computeVisibility(input: VisibilityInput): VisibilityResult { if (status.state === 'idle') { return 'hidden' } + if (status.state === 'available' && !userInitiatedCycle && !isNudgeDriven) { + return 'hidden' + } + if ( + status.state === 'downloading' && + !userInitiatedCycle && + !hasStartedDownload && + !isNudgeDriven + ) { + return 'hidden' + } + if ( + status.state === 'downloaded' && + !userInitiatedCycle && + !hasStartedDownload && + !isNudgeDriven && + !isUserInitiated + ) { + return 'hidden' + } if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) { return 'hidden' } @@ -383,7 +411,7 @@ describe('UpdateCard visibility gates', () => { ).toBe('visible') }) - it('shows available update (simple mode)', () => { + it('hides background available while the update pre-download starts', () => { expect( computeVisibility({ status: { state: 'available', version: '1.2.0', changelog: null }, @@ -391,15 +419,44 @@ describe('UpdateCard visibility gates', () => { cachedVersion: null, hasStartedDownload: false }) + ).toBe('hidden') + }) + + it('shows user-initiated available update (simple mode)', () => { + expect( + computeVisibility({ + status: { state: 'available', version: '1.2.0', changelog: null }, + dismissedVersion: null, + cachedVersion: null, + hasStartedDownload: false, + userInitiatedCycle: true + }) ).toBe('visible') }) - it('shows available update (rich mode)', () => { + it('shows user-initiated available update (rich mode)', () => { expect( computeVisibility({ status: { state: 'available', version: '1.2.0', changelog: RICH_CHANGELOG }, dismissedVersion: null, cachedVersion: null, + hasStartedDownload: false, + userInitiatedCycle: true + }) + ).toBe('visible') + }) + + it('shows nudge-driven available update', () => { + expect( + computeVisibility({ + status: { + state: 'available', + version: '1.2.0', + changelog: null, + activeNudgeId: 'campaign-1' + }, + dismissedVersion: null, + cachedVersion: null, hasStartedDownload: false }) ).toBe('visible') @@ -427,6 +484,33 @@ describe('UpdateCard visibility gates', () => { ).toBe('visible') }) + it('hides ordinary background download progress before the update is ready', () => { + expect( + computeVisibility({ + status: { state: 'downloading', percent: 42, version: '1.2.0' }, + dismissedVersion: null, + cachedVersion: '1.2.0', + hasStartedDownload: false + }) + ).toBe('hidden') + }) + + it('shows nudge-driven background download progress', () => { + expect( + computeVisibility({ + status: { + state: 'downloading', + percent: 42, + version: '1.2.0', + activeNudgeId: 'campaign-1' + }, + dismissedVersion: null, + cachedVersion: '1.2.0', + hasStartedDownload: false + }) + ).toBe('visible') + }) + it('hides downloaded when version is dismissed (Settings-initiated, not card)', () => { expect( computeVisibility({ @@ -438,6 +522,28 @@ describe('UpdateCard visibility gates', () => { ).toBe('hidden') }) + it('hides ordinary background downloaded updates until the user checks', () => { + expect( + computeVisibility({ + status: { state: 'downloaded', version: '1.2.0' }, + dismissedVersion: null, + cachedVersion: '1.2.0', + hasStartedDownload: false + }) + ).toBe('hidden') + }) + + it('shows downloaded updates after a user-initiated check', () => { + expect( + computeVisibility({ + status: { state: 'downloaded', version: '1.2.0', userInitiated: true }, + dismissedVersion: null, + cachedVersion: '1.2.0', + hasStartedDownload: false + }) + ).toBe('visible') + }) + it('hides background errors silently', () => { expect( computeVisibility({ @@ -499,7 +605,8 @@ describe('UpdateCard visibility gates', () => { status: { state: 'available', version: '1.3.0', changelog: null }, dismissedVersion: '1.2.0', cachedVersion: '1.3.0', - hasStartedDownload: false + hasStartedDownload: false, + userInitiatedCycle: true }) ).toBe('visible') }) @@ -589,7 +696,8 @@ describe('full update lifecycle through setUpdateStatus', () => { status: store.getState().updateStatus, dismissedVersion: store.getState().dismissedUpdateVersion, cachedVersion: '1.3.0', - hasStartedDownload: false + hasStartedDownload: false, + userInitiatedCycle: true }) ).toBe('visible') }) diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index ce49ca1a994..78a1169c77d 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -239,6 +239,30 @@ export function UpdateCard() { return null } + const isNudgeDriven = 'activeNudgeId' in status && Boolean(status.activeNudgeId) + // Why: ordinary background checks now pre-download updates; keep that path + // quiet until the user explicitly checks, while explicit/nudge cycles stay visible. + if (status.state === 'available' && !userInitiatedCycleRef.current && !isNudgeDriven) { + return null + } + if ( + status.state === 'downloading' && + !userInitiatedCycleRef.current && + !hasStartedDownload.current && + !isNudgeDriven + ) { + return null + } + if ( + status.state === 'downloaded' && + !userInitiatedCycleRef.current && + !hasStartedDownload.current && + !isNudgeDriven && + !isUserInitiated + ) { + return null + } + // Error: show card for user-initiated check failures or for failures tied to // a concrete cached update version (card-initiated and Settings-initiated // download/install flows). Background check failures stay silent. diff --git a/src/shared/types.ts b/src/shared/types.ts index caacce496d1..d173580255a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1451,7 +1451,13 @@ export type UpdateStatus = } | { state: 'not-available'; userInitiated?: boolean } | { state: 'downloading'; percent: number; version: string; activeNudgeId?: string } - | { state: 'downloaded'; version: string; releaseUrl?: string; activeNudgeId?: string } + | { + state: 'downloaded' + version: string + releaseUrl?: string + userInitiated?: boolean + activeNudgeId?: string + } | { state: 'error'; message: string; userInitiated?: boolean; activeNudgeId?: string } // ─── Settings ────────────────────────────────────────────────────────