mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Enable signed desktop self-updates for release testing
The desktop app already had Tauri updater plumbing, but the release pipeline was not emitting signed updater artifacts and the UI exposed only a thin auto-check path. This change restores a working updater release path, rotates to a new updater public key, and adds a shared update flow with manual check/install controls for testing. Constraint: Original updater private key is unavailable, so a new public key had to be embedded and old installs cannot trust new signatures Constraint: Must not add new dependencies or require main-branch rollout before validation Rejected: Keep the old pubkey and skip signing | would leave release builds unable to publish valid updater artifacts Rejected: Add a manual download fallback flow | user explicitly deferred that work Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve the new updater private key and password outside the repo; losing them again will break future in-app updates for installed builds Tested: desktop unit test for updater store; desktop TypeScript no-emit; desktop production build Not-tested: end-to-end updater install against a real GitHub Release on this branch
This commit is contained in:
parent
c904178518
commit
45e4e447fa
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@ -127,6 +127,8 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: desktop
|
||||
tauriScript: bunx tauri
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY1RTlBNEYxNDc4NDdEMEUKUldRT2ZZUkg4YVRwOVZHUjNXSzh2R1ZsREJwL1UzU1JKOEl2WHhMMkR3RVpJbzhjS2h0Y0J1NGcK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK",
|
||||
"endpoints": [
|
||||
"https://github.com/NanmiCoder/cc-haha/releases/latest/download/latest.json"
|
||||
],
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": false
|
||||
"createUpdaterArtifacts": true
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,110 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type UpdateInfo = {
|
||||
version: string
|
||||
downloading: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
let isTauri = false
|
||||
try {
|
||||
isTauri = '__TAURI_INTERNALS__' in window
|
||||
} catch {
|
||||
// not in Tauri
|
||||
}
|
||||
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||
import { useUpdateStore } from '../../stores/updateStore'
|
||||
|
||||
export function UpdateChecker() {
|
||||
const [update, setUpdate] = useState<UpdateInfo | null>(null)
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const t = useTranslation()
|
||||
const status = useUpdateStore((s) => s.status)
|
||||
const availableVersion = useUpdateStore((s) => s.availableVersion)
|
||||
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
|
||||
const progressPercent = useUpdateStore((s) => s.progressPercent)
|
||||
const error = useUpdateStore((s) => s.error)
|
||||
const shouldPrompt = useUpdateStore((s) => s.shouldPrompt)
|
||||
const initialize = useUpdateStore((s) => s.initialize)
|
||||
const installUpdate = useUpdateStore((s) => s.installUpdate)
|
||||
const dismissPrompt = useUpdateStore((s) => s.dismissPrompt)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return
|
||||
void initialize()
|
||||
}, [initialize])
|
||||
|
||||
const checkForUpdate = async () => {
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const available = await check()
|
||||
if (available) {
|
||||
setUpdate({ version: available.version, downloading: false, progress: 0 })
|
||||
addToast({
|
||||
type: 'info',
|
||||
message: t('update.newVersion', { version: available.version }),
|
||||
duration: 0, // persist until dismissed
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Updater not configured or no network — silently ignore
|
||||
}
|
||||
}
|
||||
if (!isTauriRuntime()) return null
|
||||
|
||||
// Check after a short delay so UI loads first
|
||||
const timer = setTimeout(checkForUpdate, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [addToast])
|
||||
const showPopup =
|
||||
shouldPrompt && !!availableVersion && ['available', 'downloading', 'restarting'].includes(status)
|
||||
|
||||
if (!update || !isTauri) return null
|
||||
if (!showPopup) return null
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||
const available = await check()
|
||||
if (!available) return
|
||||
|
||||
setUpdate((u) => u && { ...u, downloading: true })
|
||||
|
||||
await available.downloadAndInstall((event) => {
|
||||
if (event.event === 'Started' && event.data.contentLength) {
|
||||
setUpdate((u) => u && { ...u, progress: 0 })
|
||||
} else if (event.event === 'Progress') {
|
||||
setUpdate((u) => {
|
||||
if (!u) return u
|
||||
return { ...u, progress: Math.min(u.progress + (event.data.chunkLength ?? 0), 100) }
|
||||
})
|
||||
} else if (event.event === 'Finished') {
|
||||
setUpdate((u) => u && { ...u, progress: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
await relaunch()
|
||||
} catch (err) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: t('update.failed', { error: err instanceof Error ? err.message : String(err) }),
|
||||
})
|
||||
setUpdate((u) => u && { ...u, downloading: false })
|
||||
}
|
||||
}
|
||||
const statusText =
|
||||
status === 'restarting'
|
||||
? t('update.restarting')
|
||||
: status === 'downloading'
|
||||
? t('update.downloading')
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[200] max-w-xs">
|
||||
<div className="fixed top-4 right-4 z-[200] max-w-sm">
|
||||
<div className="bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-dropdown)] p-4">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('update.available', { version: update.version })}
|
||||
{t('update.available', { version: availableVersion })}
|
||||
</p>
|
||||
{update.downloading ? (
|
||||
<div className="mt-2">
|
||||
|
||||
{releaseNotes && (
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] whitespace-pre-wrap line-clamp-5">
|
||||
{releaseNotes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(status === 'downloading' || status === 'restarting') && (
|
||||
<div className="mt-3">
|
||||
<div className="h-1.5 bg-[var(--color-surface)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
|
||||
style={{ width: `${Math.min(update.progress, 100)}%` }}
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">{t('update.downloading')}</p>
|
||||
{statusText && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
{statusText} {status === 'downloading' ? `${progressPercent}%` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex gap-2">
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-[var(--color-error)]">
|
||||
{t('update.failed', { error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'available' && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
onClick={() => void installUpdate()}
|
||||
className="px-3 py-1 text-xs font-medium rounded-[var(--radius-md)] bg-[var(--color-text-accent)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('update.now')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUpdate(null)}
|
||||
onClick={dismissPrompt}
|
||||
className="px-3 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
{t('update.later')}
|
||||
|
||||
@ -243,6 +243,8 @@ export const en = {
|
||||
'settings.about.starHint': 'If this project helps you, consider giving it a Star',
|
||||
'settings.about.author': 'Author',
|
||||
'settings.about.socialMedia': 'Social Media',
|
||||
'settings.about.updates': 'App Updates',
|
||||
'settings.about.updatesDesc': 'Check GitHub Releases, download the installer, and relaunch after install.',
|
||||
|
||||
// Settings > Computer Use
|
||||
'settings.tab.computerUse': 'Computer Use',
|
||||
@ -576,10 +578,20 @@ export const en = {
|
||||
|
||||
// ─── Update Checker ──────────────────────────────────────
|
||||
'update.available': 'v{version} available',
|
||||
'update.availableLabel': 'Available',
|
||||
'update.checking': 'Checking for updates...',
|
||||
'update.checkNow': 'Check now',
|
||||
'update.checkedAt': 'Last checked {time}',
|
||||
'update.currentVersionUnknown': 'Unknown',
|
||||
'update.newVersion': 'New version v{version} available',
|
||||
'update.downloading': 'Downloading...',
|
||||
'update.idle': 'Check for updates to compare your installed version with the latest GitHub Release.',
|
||||
'update.now': 'Update now',
|
||||
'update.later': 'Later',
|
||||
'update.progress': 'Downloading update... {progress}%',
|
||||
'update.releaseNotes': 'Release Notes',
|
||||
'update.restarting': 'Restarting to finish update...',
|
||||
'update.upToDate': 'You are up to date on v{version}.',
|
||||
'update.failed': 'Update failed: {error}',
|
||||
|
||||
// ─── Active Session ──────────────────────────────────────
|
||||
|
||||
@ -245,6 +245,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.about.starHint': '如果这个项目对你有帮助,欢迎给个 Star',
|
||||
'settings.about.author': '作者',
|
||||
'settings.about.socialMedia': '社交媒体',
|
||||
'settings.about.updates': '应用更新',
|
||||
'settings.about.updatesDesc': '检查 GitHub Releases,下载安装包,并在安装后自动重启。',
|
||||
|
||||
// Settings > Computer Use
|
||||
'settings.tab.computerUse': 'Computer Use',
|
||||
@ -578,10 +580,20 @@ export const zh: Record<TranslationKey, string> = {
|
||||
|
||||
// ─── 更新检查 ──────────────────────────────────────
|
||||
'update.available': 'v{version} 可用',
|
||||
'update.availableLabel': '可更新版本',
|
||||
'update.checking': '正在检查更新...',
|
||||
'update.checkNow': '检查更新',
|
||||
'update.checkedAt': '上次检查时间 {time}',
|
||||
'update.currentVersionUnknown': '未知版本',
|
||||
'update.newVersion': '新版本 v{version} 可用',
|
||||
'update.downloading': '下载中...',
|
||||
'update.idle': '点击检查更新,对比当前安装版本和 GitHub Releases 的最新版本。',
|
||||
'update.now': '立即更新',
|
||||
'update.later': '稍后',
|
||||
'update.progress': '正在下载更新... {progress}%',
|
||||
'update.releaseNotes': '更新说明',
|
||||
'update.restarting': '正在重启以完成更新...',
|
||||
'update.upToDate': '当前已是最新版本 v{version}。',
|
||||
'update.failed': '更新失败: {error}',
|
||||
|
||||
// ─── 活跃会话 ──────────────────────────────────────
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { getDefaultBaseUrl, setBaseUrl } from '../api/client'
|
||||
|
||||
function isTauriRuntime() {
|
||||
export function isTauriRuntime() {
|
||||
if (typeof window === 'undefined') return false
|
||||
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import { SkillDetail } from '../components/skills/SkillDetail'
|
||||
import { ComputerUseSettings } from './ComputerUseSettings'
|
||||
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
@ -1218,15 +1219,53 @@ const SOCIAL_LINKS = [
|
||||
function AboutSettings() {
|
||||
const t = useTranslation()
|
||||
const [version, setVersion] = useState('')
|
||||
const updateStatus = useUpdateStore((s) => s.status)
|
||||
const availableVersion = useUpdateStore((s) => s.availableVersion)
|
||||
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
|
||||
const progressPercent = useUpdateStore((s) => s.progressPercent)
|
||||
const error = useUpdateStore((s) => s.error)
|
||||
const checkedAt = useUpdateStore((s) => s.checkedAt)
|
||||
const checkForUpdates = useUpdateStore((s) => s.checkForUpdates)
|
||||
const installUpdate = useUpdateStore((s) => s.installUpdate)
|
||||
const initialize = useUpdateStore((s) => s.initialize)
|
||||
|
||||
useEffect(() => {
|
||||
import('@tauri-apps/api/app').then((mod) => mod.getVersion()).then(setVersion).catch(() => setVersion('0.1.0'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [initialize])
|
||||
|
||||
const openUrl = (url: string) => {
|
||||
import('@tauri-apps/plugin-shell').then((mod) => mod.open(url)).catch(() => window.open(url, '_blank'))
|
||||
}
|
||||
|
||||
const checkedAtText =
|
||||
checkedAt
|
||||
? new Date(checkedAt).toLocaleString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
: null
|
||||
|
||||
const updateDescription =
|
||||
updateStatus === 'checking'
|
||||
? t('update.checking')
|
||||
: updateStatus === 'downloading'
|
||||
? t('update.progress', { progress: String(progressPercent) })
|
||||
: updateStatus === 'restarting'
|
||||
? t('update.restarting')
|
||||
: updateStatus === 'available' && availableVersion
|
||||
? t('update.newVersion', { version: availableVersion })
|
||||
: updateStatus === 'up-to-date'
|
||||
? t('update.upToDate', { version: version || t('update.currentVersionUnknown') })
|
||||
: error
|
||||
? t('update.failed', { error })
|
||||
: t('update.idle')
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
|
||||
{/* Logo + App Name + Version */}
|
||||
@ -1251,6 +1290,94 @@ function AboutSettings() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.about.updates')}</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
{t('settings.about.updatesDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void checkForUpdates()}
|
||||
loading={updateStatus === 'checking'}
|
||||
>
|
||||
{t('update.checkNow')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.about.version')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
|
||||
{version || t('update.currentVersionUnknown')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableVersion && (
|
||||
<div className="text-right">
|
||||
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
|
||||
{t('update.availableLabel')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
|
||||
{availableVersion}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={`mt-3 text-sm ${error ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{updateDescription}
|
||||
</p>
|
||||
|
||||
{checkedAtText && (
|
||||
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('update.checkedAt', { time: checkedAtText })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(updateStatus === 'downloading' || updateStatus === 'restarting') && (
|
||||
<div className="mt-3">
|
||||
<div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{releaseNotes && availableVersion && (
|
||||
<div className="mt-3 rounded-lg bg-[var(--color-surface-container-low)] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
|
||||
{t('update.releaseNotes')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] whitespace-pre-wrap">
|
||||
{releaseNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableVersion && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void installUpdate()}
|
||||
loading={updateStatus === 'downloading' || updateStatus === 'restarting'}
|
||||
disabled={updateStatus === 'checking'}
|
||||
>
|
||||
{updateStatus === 'restarting' ? t('update.restarting') : t('update.now')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-full border-t border-[var(--color-border)]/40 my-6" />
|
||||
|
||||
|
||||
71
desktop/src/stores/updateStore.test.ts
Normal file
71
desktop/src/stores/updateStore.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const check = vi.fn()
|
||||
const relaunch = vi.fn()
|
||||
|
||||
vi.mock('@tauri-apps/plugin-updater', () => ({
|
||||
check,
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/plugin-process', () => ({
|
||||
relaunch,
|
||||
}))
|
||||
|
||||
describe('updateStore', () => {
|
||||
beforeEach(() => {
|
||||
check.mockReset()
|
||||
relaunch.mockReset()
|
||||
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('stores available update metadata after a successful check', async () => {
|
||||
const update = {
|
||||
version: '0.2.0',
|
||||
body: 'Bug fixes and performance improvements',
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
check.mockResolvedValue(update)
|
||||
|
||||
vi.resetModules()
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
|
||||
const result = await useUpdateStore.getState().checkForUpdates()
|
||||
|
||||
expect(result).toBe(update)
|
||||
expect(useUpdateStore.getState().status).toBe('available')
|
||||
expect(useUpdateStore.getState().availableVersion).toBe('0.2.0')
|
||||
expect(useUpdateStore.getState().releaseNotes).toBe('Bug fixes and performance improvements')
|
||||
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
|
||||
})
|
||||
|
||||
it('computes download progress from content length and relaunches after install', async () => {
|
||||
const downloadAndInstall = vi.fn(async (onEvent?: (event: unknown) => void) => {
|
||||
onEvent?.({ event: 'Started', data: { contentLength: 200 } })
|
||||
onEvent?.({ event: 'Progress', data: { chunkLength: 50 } })
|
||||
onEvent?.({ event: 'Progress', data: { chunkLength: 150 } })
|
||||
onEvent?.({ event: 'Finished' })
|
||||
})
|
||||
|
||||
check.mockResolvedValue({
|
||||
version: '0.2.0',
|
||||
body: 'Notes',
|
||||
downloadAndInstall,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
relaunch.mockResolvedValue(undefined)
|
||||
|
||||
vi.resetModules()
|
||||
const { useUpdateStore } = await import('./updateStore')
|
||||
|
||||
await useUpdateStore.getState().checkForUpdates()
|
||||
await useUpdateStore.getState().installUpdate()
|
||||
|
||||
expect(downloadAndInstall).toHaveBeenCalledTimes(1)
|
||||
expect(useUpdateStore.getState().progressPercent).toBe(100)
|
||||
expect(useUpdateStore.getState().status).toBe('restarting')
|
||||
expect(relaunch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
221
desktop/src/stores/updateStore.ts
Normal file
221
desktop/src/stores/updateStore.ts
Normal file
@ -0,0 +1,221 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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) {
|
||||
set((state) => ({
|
||||
...state,
|
||||
status: 'up-to-date',
|
||||
availableVersion: null,
|
||||
releaseNotes: null,
|
||||
progressPercent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
checkedAt,
|
||||
error: null,
|
||||
shouldPrompt: false,
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
...state,
|
||||
status: 'available',
|
||||
availableVersion: update.version,
|
||||
releaseNotes: update.body ?? null,
|
||||
progressPercent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
checkedAt,
|
||||
error: null,
|
||||
shouldPrompt: true,
|
||||
}))
|
||||
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 {
|
||||
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: () => {
|
||||
set((state) => ({
|
||||
...state,
|
||||
shouldPrompt: false,
|
||||
}))
|
||||
},
|
||||
}))
|
||||
Loading…
x
Reference in New Issue
Block a user