mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Desktop update downloads currently depend on the updater HTTP stack and can miss the proxy users already rely on for GitHub access. The change enables OS proxy discovery in the native updater client and adds a narrow manual proxy fallback scoped only to update checks and package downloads. Constraint: Tauri updater carries the proxy from check through download, so changing proxy settings after a check must discard the pending update and re-check before install Rejected: Reuse provider proxy settings | provider traffic and updater traffic have different scope and persistence risks Confidence: high Scope-risk: moderate Directive: Keep update proxy settings scoped to app updates; do not mix them with model/provider proxy configuration without a separate migration decision Tested: cd desktop && bun run test -- --run src/__tests__/generalSettings.test.tsx src/stores/updateStore.test.ts src/stores/settingsStore.test.ts src-tauri/tauri-config.test.ts Tested: bun run check:desktop Tested: bun run check:native Not-tested: Full coverage gate intentionally skipped after maintainer said it was not needed
293 lines
7.4 KiB
TypeScript
293 lines
7.4 KiB
TypeScript
import { create } from 'zustand'
|
|
import type { Update } from '@tauri-apps/plugin-updater'
|
|
import { isTauriRuntime } from '../lib/desktopRuntime'
|
|
import type { UpdateProxySettings } from '../types/settings'
|
|
import { useSettingsStore } from './settingsStore'
|
|
|
|
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 pendingUpdateProxyKey: string | 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.
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async function setPendingUpdate(next: Update | null, proxyKey: string | null) {
|
|
const previous = pendingUpdate
|
|
pendingUpdate = next
|
|
pendingUpdateProxyKey = next ? proxyKey : null
|
|
|
|
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 updateProxyKey = getUpdateProxyKey()
|
|
const update = await check(getUpdateCheckOptions())
|
|
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 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 && pendingUpdateProxyKey !== getUpdateProxyKey()) {
|
|
await setPendingUpdate(null, null)
|
|
update = null
|
|
}
|
|
if (!update) {
|
|
update = await get().checkForUpdates()
|
|
if (!update) return
|
|
}
|
|
|
|
set((state) => ({
|
|
...state,
|
|
status: 'downloading',
|
|
error: null,
|
|
shouldPrompt: true,
|
|
progressPercent: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
}))
|
|
|
|
let prepareInstallAttempted = false
|
|
try {
|
|
writeDismissedUpdateVersion(null)
|
|
const { invoke } = await import('@tauri-apps/api/core')
|
|
const { relaunch } = await import('@tauri-apps/plugin-process')
|
|
let totalBytes: number | null = null
|
|
let downloadedBytes = 0
|
|
|
|
await update.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,
|
|
}))
|
|
}
|
|
})
|
|
|
|
prepareInstallAttempted = true
|
|
await invoke('prepare_for_update_install')
|
|
await update.install()
|
|
|
|
set((state) => ({
|
|
...state,
|
|
status: 'restarting',
|
|
progressPercent: 100,
|
|
}))
|
|
|
|
await relaunch()
|
|
} catch (error) {
|
|
if (prepareInstallAttempted) {
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core')
|
|
await invoke('cancel_update_install')
|
|
} catch {
|
|
// Best effort: keep the update prompt recoverable even if native reset fails.
|
|
}
|
|
}
|
|
set((state) => ({
|
|
...state,
|
|
status: 'available',
|
|
error: getErrorMessage(error),
|
|
shouldPrompt: true,
|
|
}))
|
|
}
|
|
},
|
|
|
|
dismissPrompt: () => {
|
|
writeDismissedUpdateVersion(get().availableVersion)
|
|
set((state) => ({
|
|
...state,
|
|
shouldPrompt: false,
|
|
}))
|
|
},
|
|
}))
|