mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
5a86ab097f
commit
b7aefc3d01
@ -4,6 +4,8 @@ import '@testing-library/jest-dom'
|
|||||||
|
|
||||||
import { Settings } from '../pages/Settings'
|
import { Settings } from '../pages/Settings'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
import { useUpdateStore } from '../stores/updateStore'
|
||||||
|
|
||||||
vi.mock('../api/agents', () => ({
|
vi.mock('../api/agents', () => ({
|
||||||
agentsApi: {
|
agentsApi: {
|
||||||
@ -69,6 +71,23 @@ describe('Settings > General tab', () => {
|
|||||||
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
|
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', () => {
|
it('shows WebFetch preflight toggle enabled by default', () => {
|
||||||
@ -91,3 +110,55 @@ describe('Settings > General tab', () => {
|
|||||||
expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false)
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
65
desktop/src/components/shared/UpdateChecker.test.tsx
Normal file
65
desktop/src/components/shared/UpdateChecker.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,7 +1,9 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
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()
|
||||||
@ -9,6 +11,8 @@ export function UpdateChecker() {
|
|||||||
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 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)
|
||||||
@ -26,11 +30,15 @@ export function UpdateChecker() {
|
|||||||
|
|
||||||
if (!showPopup) return null
|
if (!showPopup) return null
|
||||||
|
|
||||||
|
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
|
||||||
|
const downloadedText = formatBytes(downloadedBytes)
|
||||||
const statusText =
|
const statusText =
|
||||||
status === 'restarting'
|
status === 'restarting'
|
||||||
? t('update.restarting')
|
? t('update.restarting')
|
||||||
: status === 'downloading'
|
: status === 'downloading'
|
||||||
? t('update.downloading')
|
? hasKnownProgress
|
||||||
|
? t('update.downloading')
|
||||||
|
: t('update.progressBytes', { downloaded: downloadedText })
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -41,22 +49,30 @@ export function UpdateChecker() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{releaseNotes && (
|
{releaseNotes && (
|
||||||
<p className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] whitespace-pre-wrap line-clamp-5">
|
<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">
|
||||||
{releaseNotes}
|
<MarkdownRenderer
|
||||||
</p>
|
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') && (
|
{(status === 'downloading' || status === 'restarting') && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="h-1.5 bg-[var(--color-surface)] rounded-full overflow-hidden">
|
<div className="h-1.5 bg-[var(--color-surface)] rounded-full overflow-hidden">
|
||||||
<div
|
{hasKnownProgress || status === 'restarting' ? (
|
||||||
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
|
<div
|
||||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
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>
|
</div>
|
||||||
{statusText && (
|
{statusText && (
|
||||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||||
{statusText} {status === 'downloading' ? `${progressPercent}%` : ''}
|
{statusText}
|
||||||
|
{status === 'downloading' && hasKnownProgress ? ` ${progressPercent}%` : ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -625,6 +625,7 @@ export const en = {
|
|||||||
'update.now': 'Update now',
|
'update.now': 'Update now',
|
||||||
'update.later': 'Later',
|
'update.later': 'Later',
|
||||||
'update.progress': 'Downloading update... {progress}%',
|
'update.progress': 'Downloading update... {progress}%',
|
||||||
|
'update.progressBytes': 'Downloading update... {downloaded} downloaded',
|
||||||
'update.releaseNotes': 'Release Notes',
|
'update.releaseNotes': 'Release Notes',
|
||||||
'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}.',
|
||||||
|
|||||||
@ -627,6 +627,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'update.now': '立即更新',
|
'update.now': '立即更新',
|
||||||
'update.later': '稍后',
|
'update.later': '稍后',
|
||||||
'update.progress': '正在下载更新... {progress}%',
|
'update.progress': '正在下载更新... {progress}%',
|
||||||
|
'update.progressBytes': '正在下载更新... 已下载 {downloaded}',
|
||||||
'update.releaseNotes': '更新说明',
|
'update.releaseNotes': '更新说明',
|
||||||
'update.restarting': '正在重启以完成更新...',
|
'update.restarting': '正在重启以完成更新...',
|
||||||
'update.upToDate': '当前已是最新版本 v{version}。',
|
'update.upToDate': '当前已是最新版本 v{version}。',
|
||||||
|
|||||||
18
desktop/src/lib/formatBytes.ts
Normal file
18
desktop/src/lib/formatBytes.ts
Normal 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]}`
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import { ComputerUseSettings } from './ComputerUseSettings'
|
|||||||
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
||||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||||
import { useUpdateStore } from '../stores/updateStore'
|
import { useUpdateStore } from '../stores/updateStore'
|
||||||
|
import { formatBytes } from '../lib/formatBytes'
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||||
@ -1279,6 +1280,8 @@ function AboutSettings() {
|
|||||||
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 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 checkedAt = useUpdateStore((s) => s.checkedAt)
|
const checkedAt = useUpdateStore((s) => s.checkedAt)
|
||||||
const checkForUpdates = useUpdateStore((s) => s.checkForUpdates)
|
const checkForUpdates = useUpdateStore((s) => s.checkForUpdates)
|
||||||
@ -1307,11 +1310,15 @@ function AboutSettings() {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
|
||||||
|
const downloadedText = formatBytes(downloadedBytes)
|
||||||
const updateDescription =
|
const updateDescription =
|
||||||
updateStatus === 'checking'
|
updateStatus === 'checking'
|
||||||
? t('update.checking')
|
? t('update.checking')
|
||||||
: updateStatus === 'downloading'
|
: updateStatus === 'downloading'
|
||||||
? t('update.progress', { progress: String(progressPercent) })
|
? hasKnownProgress
|
||||||
|
? t('update.progress', { progress: String(progressPercent) })
|
||||||
|
: t('update.progressBytes', { downloaded: downloadedText })
|
||||||
: updateStatus === 'restarting'
|
: updateStatus === 'restarting'
|
||||||
? t('update.restarting')
|
? t('update.restarting')
|
||||||
: updateStatus === 'available' && availableVersion
|
: updateStatus === 'available' && availableVersion
|
||||||
@ -1400,22 +1407,33 @@ function AboutSettings() {
|
|||||||
{(updateStatus === 'downloading' || updateStatus === 'restarting') && (
|
{(updateStatus === 'downloading' || updateStatus === 'restarting') && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">
|
<div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">
|
||||||
<div
|
{hasKnownProgress || updateStatus === 'restarting' ? (
|
||||||
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
|
<div
|
||||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
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>
|
</div>
|
||||||
|
{!hasKnownProgress && updateStatus === 'downloading' && downloadedBytes > 0 && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
{downloadedText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{releaseNotes && availableVersion && (
|
{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)]">
|
<div className="text-[11px] uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
|
||||||
{t('update.releaseNotes')}
|
{t('update.releaseNotes')}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] whitespace-pre-wrap">
|
<MarkdownRenderer
|
||||||
{releaseNotes}
|
content={releaseNotes}
|
||||||
</p>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ describe('updateStore', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
check.mockReset()
|
check.mockReset()
|
||||||
relaunch.mockReset()
|
relaunch.mockReset()
|
||||||
|
window.localStorage.clear()
|
||||||
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {},
|
value: {},
|
||||||
@ -41,6 +42,53 @@ describe('updateStore', () => {
|
|||||||
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
|
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 () => {
|
it('computes download progress from content length and relaunches after install', async () => {
|
||||||
const downloadAndInstall = vi.fn(async (onEvent?: (event: unknown) => void) => {
|
const downloadAndInstall = vi.fn(async (onEvent?: (event: unknown) => void) => {
|
||||||
onEvent?.({ event: 'Started', data: { contentLength: 200 } })
|
onEvent?.({ event: 'Started', data: { contentLength: 200 } })
|
||||||
|
|||||||
@ -15,6 +15,8 @@ type CheckOptions = {
|
|||||||
silent?: boolean
|
silent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DISMISSED_UPDATE_VERSION_KEY = 'cc-haha-dismissed-update-version'
|
||||||
|
|
||||||
type UpdateStore = {
|
type UpdateStore = {
|
||||||
status: UpdateStatus
|
status: UpdateStatus
|
||||||
availableVersion: string | null
|
availableVersion: string | null
|
||||||
@ -34,6 +36,30 @@ type UpdateStore = {
|
|||||||
let pendingUpdate: Update | null = null
|
let pendingUpdate: Update | null = null
|
||||||
let startupCheckPromise: Promise<void> | 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) {
|
async function setPendingUpdate(next: Update | null) {
|
||||||
const previous = pendingUpdate
|
const previous = pendingUpdate
|
||||||
pendingUpdate = next
|
pendingUpdate = next
|
||||||
@ -93,6 +119,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
|||||||
const checkedAt = Date.now()
|
const checkedAt = Date.now()
|
||||||
|
|
||||||
if (!update) {
|
if (!update) {
|
||||||
|
writeDismissedUpdateVersion(null)
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
...state,
|
...state,
|
||||||
status: 'up-to-date',
|
status: 'up-to-date',
|
||||||
@ -108,6 +135,9 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dismissedVersion = readDismissedUpdateVersion()
|
||||||
|
const shouldPrompt = dismissedVersion !== update.version
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
...state,
|
...state,
|
||||||
status: 'available',
|
status: 'available',
|
||||||
@ -118,7 +148,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
|||||||
totalBytes: null,
|
totalBytes: null,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
error: null,
|
error: null,
|
||||||
shouldPrompt: true,
|
shouldPrompt,
|
||||||
}))
|
}))
|
||||||
return update
|
return update
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -160,6 +190,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
writeDismissedUpdateVersion(null)
|
||||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||||
let totalBytes: number | null = null
|
let totalBytes: number | null = null
|
||||||
let downloadedBytes = 0
|
let downloadedBytes = 0
|
||||||
@ -213,6 +244,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
dismissPrompt: () => {
|
dismissPrompt: () => {
|
||||||
|
writeDismissedUpdateVersion(get().availableVersion)
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
...state,
|
...state,
|
||||||
shouldPrompt: false,
|
shouldPrompt: false,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user