Make desktop updates less noisy and more truthful

The desktop updater now renders release notes as markdown, avoids fake 0%
progress when the server omits Content-Length, and remembers when the user
has dismissed a specific release prompt so reopening the app does not nag
again for the same version.

Constraint: Existing 0.1.4 clients can receive updater events without total size metadata and users still need a manual update path in About
Rejected: Keep repeating the prompt on every launch | creates avoidable noise after an explicit later decision
Rejected: Global dismiss flag for all future releases | would hide newer versions that should prompt again
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep prompt suppression keyed to availableVersion only; About page visibility and manual update actions must remain available
Tested: bun run test src/stores/updateStore.test.ts src/components/shared/UpdateChecker.test.tsx src/__tests__/generalSettings.test.tsx; bun run lint; manual local updater validation from 0.1.4 to 0.1.5 on /Applications and an extracted v0.1.4 release bundle
Not-tested: Signed and notarized macOS distribution behavior outside this local machine
This commit is contained in:
程序员阿江(Relakkes) 2026-04-21 01:50:29 +08:00
parent 5a86ab097f
commit b7aefc3d01
9 changed files with 289 additions and 19 deletions

View File

@ -4,6 +4,8 @@ import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { useUpdateStore } from '../stores/updateStore'
vi.mock('../api/agents', () => ({
agentsApi: {
@ -69,6 +71,23 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
}),
})
useUIStore.setState({ pendingSettingsTab: null })
useUpdateStore.setState({
status: 'idle',
availableVersion: null,
releaseNotes: null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
error: null,
checkedAt: null,
shouldPrompt: false,
initialize: vi.fn().mockResolvedValue(undefined),
checkForUpdates: vi.fn().mockResolvedValue(null),
installUpdate: vi.fn().mockResolvedValue(undefined),
dismissPrompt: vi.fn(),
})
})
it('shows WebFetch preflight toggle enabled by default', () => {
@ -91,3 +110,55 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false)
})
})
describe('Settings > About tab', () => {
beforeEach(() => {
useUIStore.setState({ pendingSettingsTab: 'about' })
useUpdateStore.setState({
status: 'available',
availableVersion: '0.1.5',
releaseNotes: '# Claude Code Haha v0.1.5\n\n- Fixed updater rendering\n- Added markdown support',
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
error: null,
checkedAt: null,
shouldPrompt: true,
initialize: vi.fn().mockResolvedValue(undefined),
checkForUpdates: vi.fn().mockResolvedValue(null),
installUpdate: vi.fn().mockResolvedValue(undefined),
dismissPrompt: vi.fn(),
})
})
it('renders release notes with markdown formatting', async () => {
render(<Settings />)
expect(await screen.findByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument()
expect(screen.getByText('Fixed updater rendering')).toBeInTheDocument()
expect(screen.getByText('Added markdown support')).toBeInTheDocument()
})
it('shows downloaded bytes instead of a fake zero percent when total size is unknown', async () => {
useUpdateStore.setState({
status: 'downloading',
availableVersion: '0.1.5',
releaseNotes: '# Claude Code Haha v0.1.5',
progressPercent: 0,
downloadedBytes: 1536,
totalBytes: null,
error: null,
checkedAt: null,
shouldPrompt: true,
initialize: vi.fn().mockResolvedValue(undefined),
checkForUpdates: vi.fn().mockResolvedValue(null),
installUpdate: vi.fn().mockResolvedValue(undefined),
dismissPrompt: vi.fn(),
})
render(<Settings />)
expect(await screen.findByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument()
expect(screen.queryByText('Downloading update... 0%')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { UpdateChecker } from './UpdateChecker'
import { useUpdateStore } from '../../stores/updateStore'
describe('UpdateChecker', () => {
beforeEach(() => {
Object.defineProperty(window, '__TAURI__', {
value: {},
configurable: true,
})
useUpdateStore.setState({
status: 'available',
availableVersion: '0.1.5',
releaseNotes: '# Claude Code Haha v0.1.5\n\n[Release notes](https://example.com/releases/v0.1.5)',
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
error: null,
checkedAt: null,
shouldPrompt: true,
initialize: vi.fn().mockResolvedValue(undefined),
checkForUpdates: vi.fn().mockResolvedValue(null),
installUpdate: vi.fn().mockResolvedValue(undefined),
dismissPrompt: vi.fn(),
})
})
it('renders markdown release notes in the update prompt', () => {
render(<UpdateChecker />)
expect(screen.getByText('v0.1.5 available')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Release notes' })
expect(link).toHaveAttribute('href', 'https://example.com/releases/v0.1.5')
expect(link).toHaveAttribute('target', '_blank')
})
it('shows downloaded bytes when the updater does not provide total size', () => {
useUpdateStore.setState({
status: 'downloading',
availableVersion: '0.1.5',
releaseNotes: '# Claude Code Haha v0.1.5',
progressPercent: 0,
downloadedBytes: 1536,
totalBytes: null,
error: null,
checkedAt: null,
shouldPrompt: true,
initialize: vi.fn().mockResolvedValue(undefined),
checkForUpdates: vi.fn().mockResolvedValue(null),
installUpdate: vi.fn().mockResolvedValue(undefined),
dismissPrompt: vi.fn(),
})
render(<UpdateChecker />)
expect(screen.getByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument()
expect(screen.queryByText(/0%/)).not.toBeInTheDocument()
})
})

View File

@ -1,7 +1,9 @@
import { useEffect } from 'react'
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()
@ -9,6 +11,8 @@ export function UpdateChecker() {
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)
@ -26,11 +30,15 @@ export function UpdateChecker() {
if (!showPopup) return null
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
const downloadedText = formatBytes(downloadedBytes)
const statusText =
status === 'restarting'
? t('update.restarting')
: status === 'downloading'
? t('update.downloading')
? hasKnownProgress
? t('update.downloading')
: t('update.progressBytes', { downloaded: downloadedText })
: null
return (
@ -41,22 +49,30 @@ export function UpdateChecker() {
</p>
{releaseNotes && (
<p className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] whitespace-pre-wrap line-clamp-5">
{releaseNotes}
</p>
<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">
<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"
/>
</div>
)}
{(status === 'downloading' || status === 'restarting') && (
<div className="mt-3">
<div className="h-1.5 bg-[var(--color-surface)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
{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' ? `${progressPercent}%` : ''}
{statusText}
{status === 'downloading' && hasKnownProgress ? ` ${progressPercent}%` : ''}
</p>
)}
</div>

View File

@ -625,6 +625,7 @@ export const en = {
'update.now': 'Update now',
'update.later': 'Later',
'update.progress': 'Downloading update... {progress}%',
'update.progressBytes': 'Downloading update... {downloaded} downloaded',
'update.releaseNotes': 'Release Notes',
'update.restarting': 'Restarting to finish update...',
'update.upToDate': 'You are up to date on v{version}.',

View File

@ -627,6 +627,7 @@ export const zh: Record<TranslationKey, string> = {
'update.now': '立即更新',
'update.later': '稍后',
'update.progress': '正在下载更新... {progress}%',
'update.progressBytes': '正在下载更新... 已下载 {downloaded}',
'update.releaseNotes': '更新说明',
'update.restarting': '正在重启以完成更新...',
'update.upToDate': '当前已是最新版本 v{version}。',

View File

@ -0,0 +1,18 @@
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
const maximumFractionDigits = value >= 10 || unitIndex === 0 ? 0 : 1
return `${new Intl.NumberFormat(undefined, {
maximumFractionDigits,
}).format(value)} ${units[unitIndex]}`
}

View File

@ -22,6 +22,7 @@ import { ComputerUseSettings } from './ComputerUseSettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
import { formatBytes } from '../lib/formatBytes'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
@ -1279,6 +1280,8 @@ function AboutSettings() {
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 checkedAt = useUpdateStore((s) => s.checkedAt)
const checkForUpdates = useUpdateStore((s) => s.checkForUpdates)
@ -1307,11 +1310,15 @@ function AboutSettings() {
})
: null
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
const downloadedText = formatBytes(downloadedBytes)
const updateDescription =
updateStatus === 'checking'
? t('update.checking')
: updateStatus === 'downloading'
? t('update.progress', { progress: String(progressPercent) })
? hasKnownProgress
? t('update.progress', { progress: String(progressPercent) })
: t('update.progressBytes', { downloaded: downloadedText })
: updateStatus === 'restarting'
? t('update.restarting')
: updateStatus === 'available' && availableVersion
@ -1400,22 +1407,33 @@ function AboutSettings() {
{(updateStatus === 'downloading' || updateStatus === 'restarting') && (
<div className="mt-3">
<div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
{hasKnownProgress || updateStatus === '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>
{!hasKnownProgress && updateStatus === 'downloading' && downloadedBytes > 0 && (
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{downloadedText}
</p>
)}
</div>
)}
{releaseNotes && availableVersion && (
<div className="mt-3 rounded-lg bg-[var(--color-surface-container-low)] px-3 py-2">
<div className="mt-3 rounded-lg bg-[var(--color-surface-container-low)] px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
{t('update.releaseNotes')}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] whitespace-pre-wrap">
{releaseNotes}
</p>
<MarkdownRenderer
content={releaseNotes}
variant="document"
className="mt-2 text-[13px] leading-6 text-[var(--color-text-secondary)] [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_p]:text-[13px] [&_p]:leading-6"
/>
</div>
)}

View File

@ -15,6 +15,7 @@ describe('updateStore', () => {
beforeEach(() => {
check.mockReset()
relaunch.mockReset()
window.localStorage.clear()
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
@ -41,6 +42,53 @@ describe('updateStore', () => {
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
it('does not re-prompt for the same version after dismissing once', async () => {
check.mockResolvedValue({
version: '0.2.0',
body: 'Bug fixes and performance improvements',
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
useUpdateStore.getState().dismissPrompt()
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
expect(window.localStorage.getItem('cc-haha-dismissed-update-version')).toBe('0.2.0')
await useUpdateStore.getState().checkForUpdates({ silent: true })
expect(useUpdateStore.getState().status).toBe('available')
expect(useUpdateStore.getState().availableVersion).toBe('0.2.0')
expect(useUpdateStore.getState().shouldPrompt).toBe(false)
})
it('prompts again when a newer version is available after dismissing an older one', async () => {
check
.mockResolvedValueOnce({
version: '0.2.0',
body: 'Bug fixes and performance improvements',
close: vi.fn().mockResolvedValue(undefined),
})
.mockResolvedValueOnce({
version: '0.3.0',
body: 'New release',
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
useUpdateStore.getState().dismissPrompt()
await useUpdateStore.getState().checkForUpdates({ silent: true })
expect(useUpdateStore.getState().availableVersion).toBe('0.3.0')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
})
it('computes download progress from content length and relaunches after install', async () => {
const downloadAndInstall = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 200 } })

View File

@ -15,6 +15,8 @@ type CheckOptions = {
silent?: boolean
}
const DISMISSED_UPDATE_VERSION_KEY = 'cc-haha-dismissed-update-version'
type UpdateStore = {
status: UpdateStatus
availableVersion: string | null
@ -34,6 +36,30 @@ type UpdateStore = {
let pendingUpdate: Update | null = null
let startupCheckPromise: Promise<void> | null = null
function readDismissedUpdateVersion(): string | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage.getItem(DISMISSED_UPDATE_VERSION_KEY)
} catch {
return null
}
}
function writeDismissedUpdateVersion(version: string | null) {
if (typeof window === 'undefined') return
try {
if (version) {
window.localStorage.setItem(DISMISSED_UPDATE_VERSION_KEY, version)
} else {
window.localStorage.removeItem(DISMISSED_UPDATE_VERSION_KEY)
}
} catch {
// Ignore storage write failures.
}
}
async function setPendingUpdate(next: Update | null) {
const previous = pendingUpdate
pendingUpdate = next
@ -93,6 +119,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
const checkedAt = Date.now()
if (!update) {
writeDismissedUpdateVersion(null)
set((state) => ({
...state,
status: 'up-to-date',
@ -108,6 +135,9 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
return null
}
const dismissedVersion = readDismissedUpdateVersion()
const shouldPrompt = dismissedVersion !== update.version
set((state) => ({
...state,
status: 'available',
@ -118,7 +148,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
totalBytes: null,
checkedAt,
error: null,
shouldPrompt: true,
shouldPrompt,
}))
return update
} catch (error) {
@ -160,6 +190,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
}))
try {
writeDismissedUpdateVersion(null)
const { relaunch } = await import('@tauri-apps/plugin-process')
let totalBytes: number | null = null
let downloadedBytes = 0
@ -213,6 +244,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
},
dismissPrompt: () => {
writeDismissedUpdateVersion(get().availableVersion)
set((state) => ({
...state,
shouldPrompt: false,