cc-haha/desktop/src/stores/updateStore.ts
程序员阿江(Relakkes) a7063e838b fix(desktop): harden updater release flow (#797)
Prevent repeated update checks from clearing the pending Electron update, recover the UI when relaunch does not exit, and keep public releases draft until all updater assets are uploaded.

Tested: bun test scripts/release-update-metadata.test.ts scripts/pr/release-workflow.test.ts
Tested: cd desktop && bun run test -- src/stores/updateStore.test.ts --run
Tested: cd desktop && bun run test -- electron/services/updater.test.ts --run
Tested: git diff --check
Tested: bun run check:policy
Tested: bun run check:desktop
Not-tested: bun run check:native
Not-tested: bun run check:coverage
Not-tested: bun run verify
Confidence: medium
Scope-risk: moderate
2026-07-03 19:13:36 +08:00

496 lines
13 KiB
TypeScript

import { create } from 'zustand'
import { getDesktopHost } from '../lib/desktopHost'
import type { DesktopHost, DesktopUpdate } from '../lib/desktopHost'
import type { UpdateProxySettings } from '../types/settings'
import { useSettingsStore } from './settingsStore'
export type UpdateStatus =
| 'idle'
| 'checking'
| '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'
const RELAUNCH_WATCHDOG_MS = 15_000
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<DesktopUpdate | null>
downloadUpdate: () => Promise<void>
installUpdate: () => Promise<void>
dismissPrompt: () => void
}
let pendingUpdate: DesktopUpdate | null = null
let pendingUpdateProxyKey: string | null = null
let pendingUpdateDownloaded = false
let downloadPromise: Promise<void> | null = null
let downloadingProxyKey: string | null = null
let startupCheckPromise: Promise<void> | null = null
let relaunchWatchdog: ReturnType<typeof setTimeout> | null = null
function clearRelaunchWatchdog() {
if (!relaunchWatchdog) return
clearTimeout(relaunchWatchdog)
relaunchWatchdog = null
}
function scheduleRelaunchWatchdog(host: DesktopHost) {
clearRelaunchWatchdog()
relaunchWatchdog = setTimeout(() => {
relaunchWatchdog = null
if (useUpdateStore.getState().status !== 'restarting') return
void host.updates.cancelInstall().catch(() => undefined)
void host.runtime.getServerUrl().catch(() => undefined)
useUpdateStore.setState((state) => ({
...state,
status: 'downloaded',
error: 'Restart did not start automatically. Restart the app manually to finish installing the update.',
shouldPrompt: true,
progressPercent: 100,
}))
}, RELAUNCH_WATCHDOG_MS)
}
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.
}
}
function getUpdateProxyUrl(settings: UpdateProxySettings = useSettingsStore.getState().updateProxy) {
if (settings.mode !== 'manual') return null
const proxy = settings.url.trim()
return proxy || null
}
function getUpdateProxyKey(settings: UpdateProxySettings = useSettingsStore.getState().updateProxy) {
const proxy = getUpdateProxyUrl(settings)
return proxy ? `manual:${proxy}` : 'system'
}
function getUpdateCheckOptions() {
const proxy = getUpdateProxyUrl()
return proxy ? { proxy } : undefined
}
function getUpdateHost(): DesktopHost | null {
const host = getDesktopHost()
return host.capabilities.updates ? host : null
}
async function setPendingUpdate(next: DesktopUpdate | null, proxyKey: string | null) {
const previous = pendingUpdate
pendingUpdate = next
pendingUpdateProxyKey = next ? proxyKey : null
pendingUpdateDownloaded = false
if (!downloadPromise) {
downloadingProxyKey = null
}
if (previous && !next) {
try {
await previous.close()
} catch {
// Ignore stale resource cleanup failures.
}
}
}
function shouldPromptForVersion(version: string | null) {
return !!version && readDismissedUpdateVersion() !== version
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function parseAppVersion(version: string | null | undefined) {
const match = version?.trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)/)
if (!match) return null
return match.slice(1).map(Number) as [number, number, number]
}
function compareAppVersions(left: string | null | undefined, right: string | null | undefined) {
const leftParts = parseAppVersion(left)
const rightParts = parseAppVersion(right)
if (!leftParts || !rightParts) return null
for (let index = 0; index < leftParts.length; index += 1) {
const delta = leftParts[index]! - rightParts[index]!
if (delta !== 0) return delta
}
return 0
}
function isUpdateNewerThanCurrent(updateVersion: string, currentVersion: string | null) {
const comparison = compareAppVersions(updateVersion, currentVersion)
return comparison === null || comparison > 0
}
async function getCurrentAppVersion(host: DesktopHost) {
try {
return await host.app.getVersion()
} catch {
return null
}
}
async function closeIgnoredUpdate(update: DesktopUpdate) {
try {
await update.close()
} catch {
// Best effort: a stale same-version update should not keep the prompt alive.
}
}
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 (!getUpdateHost()) 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, autoDownload = true } = {}) => {
const host = getUpdateHost()
if (!host) return null
if (downloadPromise && get().status === 'downloading' && pendingUpdate) return pendingUpdate
clearRelaunchWatchdog()
set((state) => ({
...state,
status: 'checking',
error: null,
}))
try {
const updateProxyKey = getUpdateProxyKey()
const update = await host.updates.check(getUpdateCheckOptions())
if (update && !isUpdateNewerThanCurrent(update.version, await getCurrentAppVersion(host))) {
await closeIgnoredUpdate(update)
await setPendingUpdate(null, null)
const checkedAt = Date.now()
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
}
await setPendingUpdate(update, updateProxyKey)
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 shouldOffer = 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: 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) {
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
}
},
downloadUpdate: async () => {
if (!getUpdateHost()) return
clearRelaunchWatchdog()
let update = pendingUpdate
if (update && pendingUpdateProxyKey !== getUpdateProxyKey()) {
await setPendingUpdate(null, null)
update = null
}
if (!update) {
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: false,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
}))
const downloadingUpdate = update
downloadingProxyKey = pendingUpdateProxyKey
const activeDownload = (async () => {
let totalBytes: number | null = null
let downloadedBytes = 0
await downloadingUpdate.download((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,
}))
}
})
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 () => {
const host = getUpdateHost()
if (!host) 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
set((state) => ({
...state,
status: 'installing',
error: null,
shouldPrompt: false,
progressPercent: 100,
}))
prepareInstallAttempted = true
await host.updates.prepareInstall()
await update.install()
set((state) => ({
...state,
status: 'restarting',
progressPercent: 100,
}))
scheduleRelaunchWatchdog(host)
await host.updates.relaunch()
} catch (error) {
clearRelaunchWatchdog()
if (prepareInstallAttempted) {
try {
await host.updates.cancelInstall()
} catch {
// Best effort: keep the update prompt recoverable even if native reset fails.
}
}
set((state) => ({
...state,
status: pendingUpdateDownloaded ? 'downloaded' : 'available',
error: getErrorMessage(error),
shouldPrompt: true,
}))
}
},
dismissPrompt: () => {
writeDismissedUpdateVersion(get().availableVersion)
set((state) => ({
...state,
shouldPrompt: false,
}))
},
}))