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', () => { it('renders markdown release notes in the update prompt', () => {
useUpdateStore.setState({ status: 'downloaded' })
render(<UpdateChecker />) 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() expect(screen.getByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Release notes' }) const link = screen.getByRole('link', { name: 'Release notes' })
@ -61,7 +64,35 @@ describe('UpdateChecker', () => {
render(<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() 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 { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { isTauriRuntime } from '../../lib/desktopRuntime' import { isTauriRuntime } from '../../lib/desktopRuntime'
import { useUpdateStore } from '../../stores/updateStore' import { useUpdateStore } from '../../stores/updateStore'
import { formatBytes } from '../../lib/formatBytes'
export function UpdateChecker() { export function UpdateChecker() {
const t = useTranslation() const t = useTranslation()
const status = useUpdateStore((s) => s.status) const status = useUpdateStore((s) => s.status)
const availableVersion = useUpdateStore((s) => s.availableVersion) const availableVersion = useUpdateStore((s) => s.availableVersion)
const releaseNotes = useUpdateStore((s) => s.releaseNotes) 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 error = useUpdateStore((s) => s.error)
const shouldPrompt = useUpdateStore((s) => s.shouldPrompt) const shouldPrompt = useUpdateStore((s) => s.shouldPrompt)
const initialize = useUpdateStore((s) => s.initialize) const initialize = useUpdateStore((s) => s.initialize)
@ -25,31 +21,24 @@ export function UpdateChecker() {
if (!isTauriRuntime()) return null if (!isTauriRuntime()) return null
const showPopup = const showPopup = shouldPrompt && !!availableVersion && status === 'downloaded'
shouldPrompt && !!availableVersion && ['available', 'downloading', 'restarting'].includes(status)
if (!showPopup) return null if (!showPopup) return null
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0 const statusText = t('update.readyBody', { version: availableVersion })
const downloadedText = formatBytes(downloadedBytes)
const statusText =
status === 'restarting'
? t('update.restarting')
: status === 'downloading'
? hasKnownProgress
? t('update.downloading')
: t('update.progressBytes', { downloaded: downloadedText })
: null
return ( return (
<div className="fixed top-4 right-4 z-[200] max-w-sm"> <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-4"> <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)]"> <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> </p>
{releaseNotes && ( {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 <MarkdownRenderer
content={releaseNotes} 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" 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> </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 && ( {error && (
<p className="mt-2 text-xs text-[var(--color-error)]"> <p className="mt-2 text-xs text-[var(--color-error)]">
{t('update.failed', { error })} {t('update.failed', { error })}
</p> </p>
)} )}
{status === 'available' && ( {status === 'downloaded' && (
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<button <button
onClick={() => void installUpdate()} 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" 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>
<button <button
onClick={dismissPrompt} onClick={dismissPrompt}

View File

@ -1533,7 +1533,10 @@ export const en = {
'update.currentVersionUnknown': 'Unknown', 'update.currentVersionUnknown': 'Unknown',
'update.newVersion': 'New version v{version} available', 'update.newVersion': 'New version v{version} available',
'update.downloading': 'Downloading...', '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.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.now': 'Update now',
'update.later': 'Later', 'update.later': 'Later',
'update.progress': 'Downloading update... {progress}%', 'update.progress': 'Downloading update... {progress}%',
@ -1550,6 +1553,8 @@ export const en = {
'update.proxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.', 'update.proxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.',
'update.proxyUrlRequired': 'Enter a proxy URL.', 'update.proxyUrlRequired': 'Enter a proxy URL.',
'update.releaseNotes': 'Release Notes', '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.restarting': 'Restarting to finish update...',
'update.upToDate': 'You are up to date on v{version}.', 'update.upToDate': 'You are up to date on v{version}.',
'update.failed': 'Update failed: {error}', 'update.failed': 'Update failed: {error}',

View File

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

View File

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

View File

@ -29,9 +29,15 @@ describe('updateStore', () => {
}) })
it('stores available update metadata after a successful check', async () => { 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 = { const update = {
version: '0.2.0', version: '0.2.0',
body: 'Bug fixes and performance improvements', body: 'Bug fixes and performance improvements',
download,
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
} }
check.mockResolvedValue(update) check.mockResolvedValue(update)
@ -42,16 +48,80 @@ describe('updateStore', () => {
const result = await useUpdateStore.getState().checkForUpdates() const result = await useUpdateStore.getState().checkForUpdates()
expect(result).toBe(update) expect(result).toBe(update)
expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().availableVersion).toBe('0.2.0') expect(useUpdateStore.getState().availableVersion).toBe('0.2.0')
expect(useUpdateStore.getState().releaseNotes).toBe('Bug fixes and performance improvements') 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) 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 () => { it('passes the configured manual update proxy to update checks', async () => {
const update = { const update = {
version: '0.2.0', version: '0.2.0',
body: 'Bug fixes and performance improvements', body: 'Bug fixes and performance improvements',
download: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
} }
check.mockResolvedValue(update) check.mockResolvedValue(update)
@ -72,9 +142,14 @@ describe('updateStore', () => {
}) })
it('does not re-prompt for the same version after dismissing once', async () => { 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({ check.mockResolvedValue({
version: '0.2.0', version: '0.2.0',
body: 'Bug fixes and performance improvements', body: 'Bug fixes and performance improvements',
download,
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
}) })
@ -92,18 +167,29 @@ describe('updateStore', () => {
expect(useUpdateStore.getState().status).toBe('available') expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().availableVersion).toBe('0.2.0') expect(useUpdateStore.getState().availableVersion).toBe('0.2.0')
expect(useUpdateStore.getState().shouldPrompt).toBe(false) 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 () => { 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 check
.mockResolvedValueOnce({ .mockResolvedValueOnce({
version: '0.2.0', version: '0.2.0',
body: 'Bug fixes and performance improvements', body: 'Bug fixes and performance improvements',
download: oldDownload,
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
version: '0.3.0', version: '0.3.0',
body: 'New release', body: 'New release',
download: newDownload,
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
}) })
@ -115,10 +201,89 @@ describe('updateStore', () => {
await useUpdateStore.getState().checkForUpdates({ silent: true }) await useUpdateStore.getState().checkForUpdates({ silent: true })
expect(useUpdateStore.getState().availableVersion).toBe('0.3.0') expect(useUpdateStore.getState().availableVersion).toBe('0.3.0')
await Promise.resolve()
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().shouldPrompt).toBe(true) 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) => { const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 200 } }) onEvent?.({ event: 'Started', data: { contentLength: 200 } })
onEvent?.({ event: 'Progress', data: { chunkLength: 50 } }) onEvent?.({ event: 'Progress', data: { chunkLength: 50 } })
@ -141,6 +306,8 @@ describe('updateStore', () => {
const { useUpdateStore } = await import('./updateStore') const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates() await useUpdateStore.getState().checkForUpdates()
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
await useUpdateStore.getState().installUpdate() await useUpdateStore.getState().installUpdate()
expect(download).toHaveBeenCalledTimes(1) expect(download).toHaveBeenCalledTimes(1)
@ -200,6 +367,72 @@ describe('updateStore', () => {
expect(freshInstall).toHaveBeenCalledTimes(1) 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 () => { it('clears the native exit guard when install fails after sidecars stop', async () => {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => { const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } }) onEvent?.({ event: 'Started', data: { contentLength: 100 } })
@ -224,7 +457,7 @@ describe('updateStore', () => {
expect(invoke).toHaveBeenNthCalledWith(1, 'prepare_for_update_install') expect(invoke).toHaveBeenNthCalledWith(1, 'prepare_for_update_install')
expect(invoke).toHaveBeenNthCalledWith(2, 'cancel_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().error).toContain('installer failed')
expect(useUpdateStore.getState().shouldPrompt).toBe(true) expect(useUpdateStore.getState().shouldPrompt).toBe(true)
}) })

View File

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