mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
254 lines
6.0 KiB
TypeScript
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,
|
|
}))
|
|
},
|
|
}))
|