diff --git a/desktop/src/components/shared/UpdateChecker.test.tsx b/desktop/src/components/shared/UpdateChecker.test.tsx index e29e2785..a5799871 100644 --- a/desktop/src/components/shared/UpdateChecker.test.tsx +++ b/desktop/src/components/shared/UpdateChecker.test.tsx @@ -32,9 +32,12 @@ describe('UpdateChecker', () => { }) it('renders markdown release notes in the update prompt', () => { + useUpdateStore.setState({ status: 'downloaded' }) + render() - 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() - 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() + + 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() + + expect(screen.getByText('Update ready')).toBeInTheDocument() + expect(screen.getByText('Update failed: installer failed')).toBeInTheDocument() + expect(screen.getByText('Install and restart')).toBeInTheDocument() + }) }) diff --git a/desktop/src/components/shared/UpdateChecker.tsx b/desktop/src/components/shared/UpdateChecker.tsx index d9bec6a5..3d915881 100644 --- a/desktop/src/components/shared/UpdateChecker.tsx +++ b/desktop/src/components/shared/UpdateChecker.tsx @@ -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 ( -
-
+
+

- {t('update.available', { version: availableVersion })} + {t('update.readyTitle')} +

+

+ {statusText}

{releaseNotes && ( -
+
)} - {(status === 'downloading' || status === 'restarting') && ( -
-
- {hasKnownProgress || status === 'restarting' ? ( -
- ) : ( -
- )} -
- {statusText && ( -

- {statusText} - {status === 'downloading' && hasKnownProgress ? ` ${progressPercent}%` : ''} -

- )} -
- )} - {error && (

{t('update.failed', { error })}

)} - {status === 'available' && ( + {status === 'downloaded' && (
)} diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index fad6532f..1baac2f0 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -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((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((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((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((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) }) diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index b345cf3f..eb64792c 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -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 checkForUpdates: (options?: CheckOptions) => Promise + downloadUpdate: () => Promise installUpdate: () => Promise dismissPrompt: () => void } let pendingUpdate: Update | null = null let pendingUpdateProxyKey: string | null = null +let pendingUpdateDownloaded = false +let downloadPromise: Promise | null = null +let downloadingProxyKey: string | null = null let startupCheckPromise: Promise | 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((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((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((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((set, get) => ({ } }, - installUpdate: async () => { + downloadUpdate: async () => { if (!isTauriRuntime()) return let update = pendingUpdate @@ -200,29 +222,42 @@ export const useUpdateStore = create((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((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((set, get) => ({ } set((state) => ({ ...state, - status: 'available', + status: pendingUpdateDownloaded ? 'downloaded' : 'available', error: getErrorMessage(error), shouldPrompt: true, }))