fix: make desktop updates non-blocking

Move update downloads into the background and prompt only after the update is ready, so the global update UI no longer pins a progress panel over the app. Keep the About page as the detailed status surface and make failed downloads or installs retryable without losing the prepared update.

Constraint: Tauri updater install still needs an explicit user restart action after download
Rejected: Keep showing global download progress | it preserves the blocking top-right experience that triggered the complaint
Confidence: high
Scope-risk: moderate
Directive: Do not show the global update prompt before status is downloaded without rechecking the desktop UX complaint
Tested: bun run test -- src/stores/updateStore.test.ts src/components/shared/UpdateChecker.test.tsx --run
Tested: bun run check:desktop
Tested: bun run check:native
Tested: bun run check:coverage
Tested: bun run verify
Not-tested: Real signed release artifact update from an installed older desktop build
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 21:54:06 +08:00
parent 6ca7ee48a8
commit 1110e5ab30
7 changed files with 437 additions and 80 deletions

View File

@ -32,9 +32,12 @@ describe('UpdateChecker', () => {
})
it('renders markdown release notes in the update prompt', () => {
useUpdateStore.setState({ status: 'downloaded' })
render(<UpdateChecker />)
expect(screen.getByText('v0.1.5 available')).toBeInTheDocument()
expect(screen.getByText('Update ready')).toBeInTheDocument()
expect(screen.getByText('v0.1.5 has been downloaded. Restart when you are ready to use it.')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Release notes' })
@ -61,7 +64,35 @@ describe('UpdateChecker', () => {
render(<UpdateChecker />)
expect(screen.getByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument()
expect(screen.queryByText('Downloading update... 1.5 KB downloaded')).not.toBeInTheDocument()
expect(screen.queryByText('Update ready')).not.toBeInTheDocument()
expect(screen.queryByText(/0%/)).not.toBeInTheDocument()
})
it.each(['installing', 'restarting'] as const)('does not keep a forced prompt during %s', (status) => {
useUpdateStore.setState({
status,
availableVersion: '0.1.5',
shouldPrompt: true,
})
render(<UpdateChecker />)
expect(screen.queryByText('Update ready')).not.toBeInTheDocument()
expect(screen.queryByText('Install and restart')).not.toBeInTheDocument()
})
it('keeps the ready prompt retryable when install fails after download', () => {
useUpdateStore.setState({
status: 'downloaded',
error: 'installer failed',
shouldPrompt: true,
})
render(<UpdateChecker />)
expect(screen.getByText('Update ready')).toBeInTheDocument()
expect(screen.getByText('Update failed: installer failed')).toBeInTheDocument()
expect(screen.getByText('Install and restart')).toBeInTheDocument()
})
})

View File

@ -3,16 +3,12 @@ import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { useUpdateStore } from '../../stores/updateStore'
import { formatBytes } from '../../lib/formatBytes'
export function UpdateChecker() {
const t = useTranslation()
const status = useUpdateStore((s) => s.status)
const availableVersion = useUpdateStore((s) => s.availableVersion)
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
const progressPercent = useUpdateStore((s) => s.progressPercent)
const downloadedBytes = useUpdateStore((s) => s.downloadedBytes)
const totalBytes = useUpdateStore((s) => s.totalBytes)
const error = useUpdateStore((s) => s.error)
const shouldPrompt = useUpdateStore((s) => s.shouldPrompt)
const initialize = useUpdateStore((s) => s.initialize)
@ -25,31 +21,24 @@ export function UpdateChecker() {
if (!isTauriRuntime()) return null
const showPopup =
shouldPrompt && !!availableVersion && ['available', 'downloading', 'restarting'].includes(status)
const showPopup = shouldPrompt && !!availableVersion && status === 'downloaded'
if (!showPopup) return null
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
const downloadedText = formatBytes(downloadedBytes)
const statusText =
status === 'restarting'
? t('update.restarting')
: status === 'downloading'
? hasKnownProgress
? t('update.downloading')
: t('update.progressBytes', { downloaded: downloadedText })
: null
const statusText = t('update.readyBody', { version: availableVersion })
return (
<div className="fixed top-4 right-4 z-[200] max-w-sm">
<div className="bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-dropdown)] p-4">
<div className="fixed bottom-4 left-1/2 z-[120] w-[min(360px,calc(100vw-2rem))] -translate-x-1/2">
<div className="bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-dropdown)] p-3">
<p className="text-sm font-medium text-[var(--color-text-primary)]">
{t('update.available', { version: availableVersion })}
{t('update.readyTitle')}
</p>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)]">
{statusText}
</p>
{releaseNotes && (
<div className="mt-2 max-h-40 overflow-y-auto rounded-lg border border-[var(--color-border)]/60 bg-[var(--color-surface)]/70 px-3 py-2">
<div className="mt-2 max-h-28 overflow-y-auto rounded-lg border border-[var(--color-border)]/60 bg-[var(--color-surface)]/70 px-3 py-2">
<MarkdownRenderer
content={releaseNotes}
className="text-xs leading-5 text-[var(--color-text-secondary)] [&_h1]:mb-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:mb-1.5 [&_h2]:text-xs [&_h2]:font-semibold [&_p]:my-1.5 [&_p]:text-xs [&_p]:leading-5 [&_ul]:my-1.5 [&_ol]:my-1.5"
@ -57,40 +46,19 @@ export function UpdateChecker() {
</div>
)}
{(status === 'downloading' || status === 'restarting') && (
<div className="mt-3">
<div className="h-1.5 bg-[var(--color-surface)] rounded-full overflow-hidden">
{hasKnownProgress || status === 'restarting' ? (
<div
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
) : (
<div className="h-full w-1/3 rounded-full bg-[var(--color-text-accent)]/75 animate-pulse" />
)}
</div>
{statusText && (
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{statusText}
{status === 'downloading' && hasKnownProgress ? ` ${progressPercent}%` : ''}
</p>
)}
</div>
)}
{error && (
<p className="mt-2 text-xs text-[var(--color-error)]">
{t('update.failed', { error })}
</p>
)}
{status === 'available' && (
{status === 'downloaded' && (
<div className="mt-3 flex gap-2">
<button
onClick={() => void installUpdate()}
className="px-3 py-1 text-xs font-medium rounded-[var(--radius-md)] bg-[var(--color-text-accent)] text-white hover:opacity-90 transition-opacity"
>
{t('update.now')}
{t('update.installAndRestart')}
</button>
<button
onClick={dismissPrompt}

View File

@ -1533,7 +1533,10 @@ export const en = {
'update.currentVersionUnknown': 'Unknown',
'update.newVersion': 'New version v{version} available',
'update.downloading': 'Downloading...',
'update.downloaded': 'Update downloaded. Restart when you are ready to use it.',
'update.idle': 'Check for updates to compare your installed version with the latest GitHub Release.',
'update.installAndRestart': 'Install and restart',
'update.installing': 'Installing update...',
'update.now': 'Update now',
'update.later': 'Later',
'update.progress': 'Downloading update... {progress}%',
@ -1550,6 +1553,8 @@ export const en = {
'update.proxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.',
'update.proxyUrlRequired': 'Enter a proxy URL.',
'update.releaseNotes': 'Release Notes',
'update.readyBody': 'v{version} has been downloaded. Restart when you are ready to use it.',
'update.readyTitle': 'Update ready',
'update.restarting': 'Restarting to finish update...',
'update.upToDate': 'You are up to date on v{version}.',
'update.failed': 'Update failed: {error}',

View File

@ -1535,7 +1535,10 @@ export const zh: Record<TranslationKey, string> = {
'update.currentVersionUnknown': '未知版本',
'update.newVersion': '新版本 v{version} 可用',
'update.downloading': '下载中...',
'update.downloaded': '更新已下载。方便时重启即可使用新版。',
'update.idle': '点击检查更新,对比当前安装版本和 GitHub Releases 的最新版本。',
'update.installAndRestart': '安装并重启',
'update.installing': '正在安装更新...',
'update.now': '立即更新',
'update.later': '稍后',
'update.progress': '正在下载更新... {progress}%',
@ -1552,6 +1555,8 @@ export const zh: Record<TranslationKey, string> = {
'update.proxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。',
'update.proxyUrlRequired': '请输入代理地址。',
'update.releaseNotes': '更新说明',
'update.readyBody': 'v{version} 已下载。方便时重启即可使用新版。',
'update.readyTitle': '更新已准备好',
'update.restarting': '正在重启以完成更新...',
'update.upToDate': '当前已是最新版本 v{version}。',
'update.failed': '更新失败: {error}',

View File

@ -3591,22 +3591,21 @@ function AboutSettings() {
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
const downloadedText = formatBytes(downloadedBytes)
const updateDescription =
updateStatus === 'checking'
? t('update.checking')
: updateStatus === 'downloading'
? hasKnownProgress
? t('update.progress', { progress: String(progressPercent) })
: t('update.progressBytes', { downloaded: downloadedText })
: updateStatus === 'restarting'
? t('update.restarting')
: updateStatus === 'available' && availableVersion
? t('update.newVersion', { version: availableVersion })
: updateStatus === 'up-to-date'
? t('update.upToDate', { version: version || t('update.currentVersionUnknown') })
: error
? t('update.failed', { error })
: t('update.idle')
const updateDescription = (() => {
if (updateStatus === 'checking') return t('update.checking')
if (error) return t('update.failed', { error })
if (updateStatus === 'downloading') {
return hasKnownProgress
? t('update.progress', { progress: String(progressPercent) })
: t('update.progressBytes', { downloaded: downloadedText })
}
if (updateStatus === 'downloaded') return t('update.downloaded')
if (updateStatus === 'installing') return t('update.installing')
if (updateStatus === 'restarting') return t('update.restarting')
if (updateStatus === 'available' && availableVersion) return t('update.newVersion', { version: availableVersion })
if (updateStatus === 'up-to-date') return t('update.upToDate', { version: version || t('update.currentVersionUnknown') })
return t('update.idle')
})()
return (
<div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
@ -3812,10 +3811,16 @@ function AboutSettings() {
<Button
size="sm"
onClick={() => void installUpdate()}
loading={updateStatus === 'downloading' || updateStatus === 'restarting'}
disabled={updateStatus === 'checking'}
loading={updateStatus === 'downloading' || updateStatus === 'installing' || updateStatus === 'restarting'}
disabled={updateStatus === 'checking' || updateStatus === 'downloading'}
>
{updateStatus === 'restarting' ? t('update.restarting') : t('update.now')}
{updateStatus === 'downloaded'
? t('update.installAndRestart')
: updateStatus === 'installing'
? t('update.installing')
: updateStatus === 'restarting'
? t('update.restarting')
: t('update.now')}
</Button>
</div>
)}

View File

@ -29,9 +29,15 @@ describe('updateStore', () => {
})
it('stores available update metadata after a successful check', async () => {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 200 } })
onEvent?.({ event: 'Progress', data: { chunkLength: 200 } })
onEvent?.({ event: 'Finished' })
})
const update = {
version: '0.2.0',
body: 'Bug fixes and performance improvements',
download,
close: vi.fn().mockResolvedValue(undefined),
}
check.mockResolvedValue(update)
@ -42,16 +48,80 @@ describe('updateStore', () => {
const result = await useUpdateStore.getState().checkForUpdates()
expect(result).toBe(update)
expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().availableVersion).toBe('0.2.0')
expect(useUpdateStore.getState().releaseNotes).toBe('Bug fixes and performance improvements')
expect(download).toHaveBeenCalledTimes(1)
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
it('does not show the global prompt while a background download is still running', async () => {
let finishDownload!: () => void
const download = vi.fn(
() =>
new Promise<void>((resolve) => {
finishDownload = resolve
}),
)
check.mockResolvedValue({
version: '0.2.0',
body: 'Notes',
download,
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
expect(download).toHaveBeenCalledTimes(1)
expect(useUpdateStore.getState().status).toBe('downloading')
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
finishDownload()
await Promise.resolve()
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
it('reuses the in-flight background download when checking again', async () => {
let finishDownload!: () => void
const download = vi.fn(
() =>
new Promise<void>((resolve) => {
finishDownload = resolve
}),
)
const update = {
version: '0.2.0',
body: 'Notes',
download,
close: vi.fn().mockResolvedValue(undefined),
}
check.mockResolvedValue(update)
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
await useUpdateStore.getState().checkForUpdates()
expect(check).toHaveBeenCalledTimes(1)
expect(download).toHaveBeenCalledTimes(1)
finishDownload()
await Promise.resolve()
expect(useUpdateStore.getState().status).toBe('downloaded')
})
it('passes the configured manual update proxy to update checks', async () => {
const update = {
version: '0.2.0',
body: 'Bug fixes and performance improvements',
download: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
}
check.mockResolvedValue(update)
@ -72,9 +142,14 @@ describe('updateStore', () => {
})
it('does not re-prompt for the same version after dismissing once', async () => {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
onEvent?.({ event: 'Finished' })
})
check.mockResolvedValue({
version: '0.2.0',
body: 'Bug fixes and performance improvements',
download,
close: vi.fn().mockResolvedValue(undefined),
})
@ -92,18 +167,29 @@ describe('updateStore', () => {
expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().availableVersion).toBe('0.2.0')
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
expect(download).toHaveBeenCalledTimes(1)
})
it('prompts again when a newer version is available after dismissing an older one', async () => {
const oldDownload = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
onEvent?.({ event: 'Finished' })
})
const newDownload = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
onEvent?.({ event: 'Finished' })
})
check
.mockResolvedValueOnce({
version: '0.2.0',
body: 'Bug fixes and performance improvements',
download: oldDownload,
close: vi.fn().mockResolvedValue(undefined),
})
.mockResolvedValueOnce({
version: '0.3.0',
body: 'New release',
download: newDownload,
close: vi.fn().mockResolvedValue(undefined),
})
@ -115,10 +201,89 @@ describe('updateStore', () => {
await useUpdateStore.getState().checkForUpdates({ silent: true })
expect(useUpdateStore.getState().availableVersion).toBe('0.3.0')
await Promise.resolve()
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
it('downloads, stops sidecars, installs, and relaunches', async () => {
it('checks and downloads when manual download starts without a pending update', async () => {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
onEvent?.({ event: 'Finished' })
})
check.mockResolvedValue({
version: '0.2.0',
body: 'Notes',
download,
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().downloadUpdate()
expect(check).toHaveBeenCalledTimes(1)
expect(download).toHaveBeenCalledTimes(1)
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
it('does not download again when the pending update is already downloaded', async () => {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
onEvent?.({ event: 'Finished' })
})
check.mockResolvedValue({
version: '0.2.0',
body: 'Notes',
download,
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
await useUpdateStore.getState().downloadUpdate()
expect(download).toHaveBeenCalledTimes(1)
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().progressPercent).toBe(100)
})
it('reuses an in-flight manual download', async () => {
let finishDownload!: () => void
const download = vi.fn(
() =>
new Promise<void>((resolve) => {
finishDownload = resolve
}),
)
check.mockResolvedValue({
version: '0.2.0',
body: 'Notes',
download,
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates({ autoDownload: false })
const firstDownload = useUpdateStore.getState().downloadUpdate()
await Promise.resolve()
const secondDownload = useUpdateStore.getState().downloadUpdate()
expect(download).toHaveBeenCalledTimes(1)
finishDownload()
await Promise.all([firstDownload, secondDownload])
expect(useUpdateStore.getState().status).toBe('downloaded')
})
it('downloads in the background, then installs and relaunches without downloading again', async () => {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 200 } })
onEvent?.({ event: 'Progress', data: { chunkLength: 50 } })
@ -141,6 +306,8 @@ describe('updateStore', () => {
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
await useUpdateStore.getState().installUpdate()
expect(download).toHaveBeenCalledTimes(1)
@ -200,6 +367,72 @@ describe('updateStore', () => {
expect(freshInstall).toHaveBeenCalledTimes(1)
})
it('does not publish update-ready when the proxy changes during download', async () => {
let finishDownload!: () => void
const download = vi.fn(
() =>
new Promise<void>((resolve) => {
finishDownload = resolve
}),
)
check.mockResolvedValue({
version: '0.2.0',
body: 'Notes',
download,
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useSettingsStore } = await import('./settingsStore')
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
useSettingsStore.setState({
updateProxy: {
mode: 'manual',
url: 'http://127.0.0.1:7890',
},
})
finishDownload()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
})
it('keeps the update available and retryable when background download fails', async () => {
const download = vi
.fn()
.mockRejectedValueOnce(new Error('network dropped'))
.mockResolvedValueOnce(undefined)
check.mockResolvedValue({
version: '0.2.0',
body: 'Notes',
download,
install: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
await Promise.resolve()
await Promise.resolve()
expect(download).toHaveBeenCalledTimes(1)
expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().error).toContain('network dropped')
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
await useUpdateStore.getState().downloadUpdate()
expect(download).toHaveBeenCalledTimes(2)
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
it('clears the native exit guard when install fails after sidecars stop', async () => {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
@ -224,7 +457,7 @@ describe('updateStore', () => {
expect(invoke).toHaveBeenNthCalledWith(1, 'prepare_for_update_install')
expect(invoke).toHaveBeenNthCalledWith(2, 'cancel_update_install')
expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().error).toContain('installer failed')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})

View File

@ -10,11 +10,14 @@ export type UpdateStatus =
| 'available'
| 'up-to-date'
| 'downloading'
| 'downloaded'
| 'installing'
| 'restarting'
| 'error'
type CheckOptions = {
silent?: boolean
autoDownload?: boolean
}
const DISMISSED_UPDATE_VERSION_KEY = 'cc-haha-dismissed-update-version'
@ -31,12 +34,16 @@ type UpdateStore = {
shouldPrompt: boolean
initialize: () => Promise<void>
checkForUpdates: (options?: CheckOptions) => Promise<Update | null>
downloadUpdate: () => Promise<void>
installUpdate: () => Promise<void>
dismissPrompt: () => void
}
let pendingUpdate: Update | null = null
let pendingUpdateProxyKey: string | null = null
let pendingUpdateDownloaded = false
let downloadPromise: Promise<void> | null = null
let downloadingProxyKey: string | null = null
let startupCheckPromise: Promise<void> | null = null
function readDismissedUpdateVersion(): string | null {
@ -83,6 +90,10 @@ async function setPendingUpdate(next: Update | null, proxyKey: string | null) {
const previous = pendingUpdate
pendingUpdate = next
pendingUpdateProxyKey = next ? proxyKey : null
pendingUpdateDownloaded = false
if (!downloadPromise) {
downloadingProxyKey = null
}
if (previous && previous !== next) {
try {
@ -93,6 +104,10 @@ async function setPendingUpdate(next: Update | null, proxyKey: string | null) {
}
}
function shouldPromptForVersion(version: string | null) {
return !!version && readDismissedUpdateVersion() !== version
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
@ -122,8 +137,9 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
await startupCheckPromise
},
checkForUpdates: async ({ silent = false } = {}) => {
checkForUpdates: async ({ silent = false, autoDownload = true } = {}) => {
if (!isTauriRuntime()) return null
if (downloadPromise && get().status === 'downloading' && pendingUpdate) return pendingUpdate
set((state) => ({
...state,
@ -157,7 +173,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
}
const dismissedVersion = readDismissedUpdateVersion()
const shouldPrompt = dismissedVersion !== update.version
const shouldOffer = dismissedVersion !== update.version
set((state) => ({
...state,
@ -169,8 +185,14 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
totalBytes: null,
checkedAt,
error: null,
shouldPrompt,
shouldPrompt: false,
}))
if (autoDownload && (shouldOffer || !silent)) {
void get().downloadUpdate().catch(() => {
// The store records the failure and keeps the manual install path retryable.
})
}
return update
} catch (error) {
if (!silent) {
@ -191,7 +213,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
}
},
installUpdate: async () => {
downloadUpdate: async () => {
if (!isTauriRuntime()) return
let update = pendingUpdate
@ -200,29 +222,42 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
update = null
}
if (!update) {
update = await get().checkForUpdates()
update = await get().checkForUpdates({ autoDownload: false })
if (!update) return
}
if (pendingUpdateDownloaded) {
set((state) => ({
...state,
status: 'downloaded',
progressPercent: 100,
shouldPrompt: shouldPromptForVersion(state.availableVersion),
}))
return
}
if (downloadPromise) {
await downloadPromise
return
}
set((state) => ({
...state,
status: 'downloading',
error: null,
shouldPrompt: true,
shouldPrompt: false,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
}))
let prepareInstallAttempted = false
try {
writeDismissedUpdateVersion(null)
const { invoke } = await import('@tauri-apps/api/core')
const { relaunch } = await import('@tauri-apps/plugin-process')
const downloadingUpdate = update
downloadingProxyKey = pendingUpdateProxyKey
const activeDownload = (async () => {
let totalBytes: number | null = null
let downloadedBytes = 0
await update.download((event) => {
await downloadingUpdate.download((event) => {
if (event.event === 'Started') {
totalBytes = event.data.contentLength ?? null
downloadedBytes = 0
@ -253,6 +288,81 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
}
})
if (pendingUpdate !== downloadingUpdate) return
if (getUpdateProxyKey() !== downloadingProxyKey) {
await setPendingUpdate(null, null)
set((state) => ({
...state,
status: 'available',
progressPercent: 0,
shouldPrompt: false,
}))
return
}
pendingUpdateDownloaded = true
set((state) => ({
...state,
status: 'downloaded',
error: null,
shouldPrompt: shouldPromptForVersion(state.availableVersion),
progressPercent: 100,
}))
})()
downloadPromise = activeDownload
try {
await downloadPromise
} catch (error) {
if (pendingUpdate === downloadingUpdate) {
set((state) => ({
...state,
status: 'available',
error: getErrorMessage(error),
shouldPrompt: false,
}))
}
throw error
} finally {
if (downloadPromise === activeDownload) {
downloadPromise = null
downloadingProxyKey = null
}
}
},
installUpdate: async () => {
if (!isTauriRuntime()) return
let update = pendingUpdate
if (update && pendingUpdateProxyKey !== getUpdateProxyKey()) {
await setPendingUpdate(null, null)
update = null
}
if (!update) {
update = await get().checkForUpdates({ autoDownload: false })
if (!update) return
}
let prepareInstallAttempted = false
try {
writeDismissedUpdateVersion(null)
if (!pendingUpdateDownloaded) {
await get().downloadUpdate()
}
if (!pendingUpdateDownloaded) return
const { invoke } = await import('@tauri-apps/api/core')
const { relaunch } = await import('@tauri-apps/plugin-process')
set((state) => ({
...state,
status: 'installing',
error: null,
shouldPrompt: false,
progressPercent: 100,
}))
prepareInstallAttempted = true
await invoke('prepare_for_update_install')
await update.install()
@ -275,7 +385,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
}
set((state) => ({
...state,
status: 'available',
status: pendingUpdateDownloaded ? 'downloaded' : 'available',
error: getErrorMessage(error),
shouldPrompt: true,
}))