Implement background update downloads

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-28 21:19:43 -07:00
co-authored by Orca
parent 09e3dfbc23
commit 7b5b57523b
8 changed files with 423 additions and 14 deletions
+49
View File
@@ -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.
+1 -1
View File
@@ -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()
}
+1 -1
View File
@@ -44,7 +44,7 @@ const {
eventHandlers.clear()
on.mockClear()
autoUpdaterMock.checkForUpdates.mockReset()
autoUpdaterMock.downloadUpdate.mockReset()
autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([])
autoUpdaterMock.quitAndInstall.mockReset()
}
+191 -1
View File
@@ -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
View File
@@ -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()
}
+113 -5
View File
@@ -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
View File
@@ -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 ────────────────────────────────────────────────────────