mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
Merge pull request #69 from NanmiCoder/codex/updater-release-test
Enable signed desktop self-updates for release testing
This commit is contained in:
commit
d3c62a8bc5
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
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
with:
|
||||||
projectPath: desktop
|
projectPath: desktop
|
||||||
tauriScript: bunx tauri
|
tauriScript: bunx tauri
|
||||||
|
|||||||
@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY1RTlBNEYxNDc4NDdEMEUKUldRT2ZZUkg4YVRwOVZHUjNXSzh2R1ZsREJwL1UzU1JKOEl2WHhMMkR3RVpJbzhjS2h0Y0J1NGcK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://github.com/NanmiCoder/cc-haha/releases/latest/download/latest.json"
|
"https://github.com/NanmiCoder/cc-haha/releases/latest/download/latest.json"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"createUpdaterArtifacts": false
|
"createUpdaterArtifacts": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,110 +1,83 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useUIStore } from '../../stores/uiStore'
|
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||||
type UpdateInfo = {
|
import { useUpdateStore } from '../../stores/updateStore'
|
||||||
version: string
|
|
||||||
downloading: boolean
|
|
||||||
progress: number
|
|
||||||
}
|
|
||||||
|
|
||||||
let isTauri = false
|
|
||||||
try {
|
|
||||||
isTauri = '__TAURI_INTERNALS__' in window
|
|
||||||
} catch {
|
|
||||||
// not in Tauri
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpdateChecker() {
|
export function UpdateChecker() {
|
||||||
const [update, setUpdate] = useState<UpdateInfo | null>(null)
|
|
||||||
const addToast = useUIStore((s) => s.addToast)
|
|
||||||
const t = useTranslation()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return
|
void initialize()
|
||||||
|
}, [initialize])
|
||||||
|
|
||||||
const checkForUpdate = async () => {
|
if (!isTauriRuntime()) return null
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check after a short delay so UI loads first
|
const showPopup =
|
||||||
const timer = setTimeout(checkForUpdate, 5000)
|
shouldPrompt && !!availableVersion && ['available', 'downloading', 'restarting'].includes(status)
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [addToast])
|
|
||||||
|
|
||||||
if (!update || !isTauri) return null
|
if (!showPopup) return null
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const statusText =
|
||||||
try {
|
status === 'restarting'
|
||||||
const { check } = await import('@tauri-apps/plugin-updater')
|
? t('update.restarting')
|
||||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
: status === 'downloading'
|
||||||
const available = await check()
|
? t('update.downloading')
|
||||||
if (!available) return
|
: null
|
||||||
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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">
|
<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)]">
|
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
{t('update.available', { version: update.version })}
|
{t('update.available', { version: availableVersion })}
|
||||||
</p>
|
</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-1.5 bg-[var(--color-surface)] rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
|
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>
|
</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>
|
||||||
) : (
|
)}
|
||||||
<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
|
<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"
|
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')}
|
{t('update.now')}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="px-3 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{t('update.later')}
|
{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.starHint': 'If this project helps you, consider giving it a Star',
|
||||||
'settings.about.author': 'Author',
|
'settings.about.author': 'Author',
|
||||||
'settings.about.socialMedia': 'Social Media',
|
'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 > Computer Use
|
||||||
'settings.tab.computerUse': 'Computer Use',
|
'settings.tab.computerUse': 'Computer Use',
|
||||||
@ -576,10 +578,20 @@ export const en = {
|
|||||||
|
|
||||||
// ─── Update Checker ──────────────────────────────────────
|
// ─── Update Checker ──────────────────────────────────────
|
||||||
'update.available': 'v{version} available',
|
'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.newVersion': 'New version v{version} available',
|
||||||
'update.downloading': 'Downloading...',
|
'update.downloading': 'Downloading...',
|
||||||
|
'update.idle': 'Check for updates to compare your installed version with the latest GitHub Release.',
|
||||||
'update.now': 'Update now',
|
'update.now': 'Update now',
|
||||||
'update.later': 'Later',
|
'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}',
|
'update.failed': 'Update failed: {error}',
|
||||||
|
|
||||||
// ─── Active Session ──────────────────────────────────────
|
// ─── Active Session ──────────────────────────────────────
|
||||||
|
|||||||
@ -245,6 +245,8 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.about.starHint': '如果这个项目对你有帮助,欢迎给个 Star',
|
'settings.about.starHint': '如果这个项目对你有帮助,欢迎给个 Star',
|
||||||
'settings.about.author': '作者',
|
'settings.about.author': '作者',
|
||||||
'settings.about.socialMedia': '社交媒体',
|
'settings.about.socialMedia': '社交媒体',
|
||||||
|
'settings.about.updates': '应用更新',
|
||||||
|
'settings.about.updatesDesc': '检查 GitHub Releases,下载安装包,并在安装后自动重启。',
|
||||||
|
|
||||||
// Settings > Computer Use
|
// Settings > Computer Use
|
||||||
'settings.tab.computerUse': 'Computer Use',
|
'settings.tab.computerUse': 'Computer Use',
|
||||||
@ -578,10 +580,20 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// ─── 更新检查 ──────────────────────────────────────
|
// ─── 更新检查 ──────────────────────────────────────
|
||||||
'update.available': 'v{version} 可用',
|
'update.available': 'v{version} 可用',
|
||||||
|
'update.availableLabel': '可更新版本',
|
||||||
|
'update.checking': '正在检查更新...',
|
||||||
|
'update.checkNow': '检查更新',
|
||||||
|
'update.checkedAt': '上次检查时间 {time}',
|
||||||
|
'update.currentVersionUnknown': '未知版本',
|
||||||
'update.newVersion': '新版本 v{version} 可用',
|
'update.newVersion': '新版本 v{version} 可用',
|
||||||
'update.downloading': '下载中...',
|
'update.downloading': '下载中...',
|
||||||
|
'update.idle': '点击检查更新,对比当前安装版本和 GitHub Releases 的最新版本。',
|
||||||
'update.now': '立即更新',
|
'update.now': '立即更新',
|
||||||
'update.later': '稍后',
|
'update.later': '稍后',
|
||||||
|
'update.progress': '正在下载更新... {progress}%',
|
||||||
|
'update.releaseNotes': '更新说明',
|
||||||
|
'update.restarting': '正在重启以完成更新...',
|
||||||
|
'update.upToDate': '当前已是最新版本 v{version}。',
|
||||||
'update.failed': '更新失败: {error}',
|
'update.failed': '更新失败: {error}',
|
||||||
|
|
||||||
// ─── 活跃会话 ──────────────────────────────────────
|
// ─── 活跃会话 ──────────────────────────────────────
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { getDefaultBaseUrl, setBaseUrl } from '../api/client'
|
import { getDefaultBaseUrl, setBaseUrl } from '../api/client'
|
||||||
|
|
||||||
function isTauriRuntime() {
|
export function isTauriRuntime() {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false
|
||||||
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
|
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { SkillDetail } from '../components/skills/SkillDetail'
|
|||||||
import { ComputerUseSettings } from './ComputerUseSettings'
|
import { ComputerUseSettings } from './ComputerUseSettings'
|
||||||
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
||||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||||
|
import { useUpdateStore } from '../stores/updateStore'
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||||
@ -1218,15 +1219,53 @@ const SOCIAL_LINKS = [
|
|||||||
function AboutSettings() {
|
function AboutSettings() {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [version, setVersion] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
import('@tauri-apps/api/app').then((mod) => mod.getVersion()).then(setVersion).catch(() => setVersion('0.1.0'))
|
import('@tauri-apps/api/app').then((mod) => mod.getVersion()).then(setVersion).catch(() => setVersion('0.1.0'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void initialize()
|
||||||
|
}, [initialize])
|
||||||
|
|
||||||
const openUrl = (url: string) => {
|
const openUrl = (url: string) => {
|
||||||
import('@tauri-apps/plugin-shell').then((mod) => mod.open(url)).catch(() => window.open(url, '_blank'))
|
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 (
|
return (
|
||||||
<div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
|
<div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
|
||||||
{/* Logo + App Name + Version */}
|
{/* Logo + App Name + Version */}
|
||||||
@ -1251,6 +1290,94 @@ function AboutSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Divider */}
|
||||||
<div className="w-full border-t border-[var(--color-border)]/40 my-6" />
|
<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