cc-haha/desktop/src/stores/updateStore.ts
程序员阿江(Relakkes) b7aefc3d01 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
2026-04-21 01:50:29 +08:00

254 lines
6.0 KiB
TypeScript

import { create } from 'zustand'
import type { Update } from '@tauri-apps/plugin-updater'
import { isTauriRuntime } from '../lib/desktopRuntime'
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'up-to-date'
| 'downloading'
| 'restarting'
| 'error'
type CheckOptions = {
silent?: boolean
}
const DISMISSED_UPDATE_VERSION_KEY = 'cc-haha-dismissed-update-version'
type UpdateStore = {
status: UpdateStatus
availableVersion: string | null
releaseNotes: string | null
progressPercent: number
downloadedBytes: number
totalBytes: number | null
error: string | null
checkedAt: number | null
shouldPrompt: boolean
initialize: () => Promise<void>
checkForUpdates: (options?: CheckOptions) => Promise<Update | null>
installUpdate: () => Promise<void>
dismissPrompt: () => void
}
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
if (previous && previous !== next) {
try {
await previous.close()
} catch {
// Ignore stale resource cleanup failures.
}
}
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
export const useUpdateStore = create<UpdateStore>((set, get) => ({
status: 'idle',
availableVersion: null,
releaseNotes: null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
error: null,
checkedAt: null,
shouldPrompt: false,
initialize: async () => {
if (!isTauriRuntime()) return
if (!startupCheckPromise) {
startupCheckPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 5000))
await get().checkForUpdates({ silent: true })
})().finally(() => {
startupCheckPromise = null
})
}
await startupCheckPromise
},
checkForUpdates: async ({ silent = false } = {}) => {
if (!isTauriRuntime()) return null
set((state) => ({
...state,
status: 'checking',
error: null,
}))
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
await setPendingUpdate(update)
const checkedAt = Date.now()
if (!update) {
writeDismissedUpdateVersion(null)
set((state) => ({
...state,
status: 'up-to-date',
availableVersion: null,
releaseNotes: null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
checkedAt,
error: null,
shouldPrompt: false,
}))
return null
}
const dismissedVersion = readDismissedUpdateVersion()
const shouldPrompt = dismissedVersion !== update.version
set((state) => ({
...state,
status: 'available',
availableVersion: update.version,
releaseNotes: update.body ?? null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
checkedAt,
error: null,
shouldPrompt,
}))
return update
} catch (error) {
if (!silent) {
set((state) => ({
...state,
status: 'error',
error: getErrorMessage(error),
checkedAt: Date.now(),
}))
} else {
set((state) => ({
...state,
status: state.availableVersion ? 'available' : 'idle',
checkedAt: Date.now(),
}))
}
return null
}
},
installUpdate: async () => {
if (!isTauriRuntime()) return
let update = pendingUpdate
if (!update) {
update = await get().checkForUpdates()
if (!update) return
}
set((state) => ({
...state,
status: 'downloading',
error: null,
shouldPrompt: true,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
}))
try {
writeDismissedUpdateVersion(null)
const { relaunch } = await import('@tauri-apps/plugin-process')
let totalBytes: number | null = null
let downloadedBytes = 0
await update.downloadAndInstall((event) => {
if (event.event === 'Started') {
totalBytes = event.data.contentLength ?? null
downloadedBytes = 0
set((state) => ({
...state,
totalBytes,
downloadedBytes: 0,
progressPercent: 0,
}))
} else if (event.event === 'Progress') {
downloadedBytes += event.data.chunkLength
const progressPercent =
totalBytes && totalBytes > 0
? Math.min(Math.round((downloadedBytes / totalBytes) * 100), 100)
: 0
set((state) => ({
...state,
downloadedBytes,
totalBytes,
progressPercent,
}))
} else if (event.event === 'Finished') {
set((state) => ({
...state,
progressPercent: 100,
}))
}
})
set((state) => ({
...state,
status: 'restarting',
progressPercent: 100,
}))
await relaunch()
} catch (error) {
set((state) => ({
...state,
status: 'available',
error: getErrorMessage(error),
shouldPrompt: true,
}))
}
},
dismissPrompt: () => {
writeDismissedUpdateVersion(get().availableVersion)
set((state) => ({
...state,
shouldPrompt: false,
}))
},
}))