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:
程序员阿江(Relakkes) 2026-04-19 16:54:45 +08:00
parent c904178518
commit 45e4e447fa
10 changed files with 501 additions and 83 deletions

View File

@ -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

View File

@ -29,7 +29,7 @@
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY1RTlBNEYxNDc4NDdEMEUKUldRT2ZZUkg4YVRwOVZHUjNXSzh2R1ZsREJwL1UzU1JKOEl2WHhMMkR3RVpJbzhjS2h0Y0J1NGcK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK",
"endpoints": [
"https://github.com/NanmiCoder/cc-haha/releases/latest/download/latest.json"
],

View File

@ -1,5 +1,5 @@
{
"bundle": {
"createUpdaterArtifacts": false
"createUpdaterArtifacts": true
}
}

View File

@ -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')}

View File

@ -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 ──────────────────────────────────────

View File

@ -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}',
// ─── 活跃会话 ──────────────────────────────────────

View File

@ -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
}

View File

@ -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" />

View 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)
})
})

View 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,
}))
},
}))