mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
Implement background update downloads
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const {
|
||||
eventHandlers.clear()
|
||||
on.mockClear()
|
||||
autoUpdaterMock.checkForUpdates.mockReset()
|
||||
autoUpdaterMock.downloadUpdate.mockReset()
|
||||
autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([])
|
||||
autoUpdaterMock.quitAndInstall.mockReset()
|
||||
}
|
||||
|
||||
|
||||
+191
-1
@@ -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 } }
|
||||
|
||||
+37
-5
@@ -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<void> | null = null
|
||||
let autoUpdateCheckTimer: ReturnType<typeof setTimeout> | 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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
+7
-1
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user