mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
6ca7ee48a8
commit
1110e5ab30
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user