From 45e4e447fad45b4afc7d9981a6bdac4f26874a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 19 Apr 2026 16:54:45 +0800 Subject: [PATCH] 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 --- .github/workflows/release-desktop.yml | 2 + desktop/src-tauri/tauri.conf.json | 2 +- desktop/src-tauri/tauri.release-ci.json | 2 +- .../src/components/shared/UpdateChecker.tsx | 133 +++++------ desktop/src/i18n/locales/en.ts | 12 + desktop/src/i18n/locales/zh.ts | 12 + desktop/src/lib/desktopRuntime.ts | 2 +- desktop/src/pages/Settings.tsx | 127 ++++++++++ desktop/src/stores/updateStore.test.ts | 71 ++++++ desktop/src/stores/updateStore.ts | 221 ++++++++++++++++++ 10 files changed, 501 insertions(+), 83 deletions(-) create mode 100644 desktop/src/stores/updateStore.test.ts create mode 100644 desktop/src/stores/updateStore.ts diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 171efeaf..f1661546 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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 diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 318e2e0f..4e065307 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ }, "plugins": { "updater": { - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY1RTlBNEYxNDc4NDdEMEUKUldRT2ZZUkg4YVRwOVZHUjNXSzh2R1ZsREJwL1UzU1JKOEl2WHhMMkR3RVpJbzhjS2h0Y0J1NGcK", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK", "endpoints": [ "https://github.com/NanmiCoder/cc-haha/releases/latest/download/latest.json" ], diff --git a/desktop/src-tauri/tauri.release-ci.json b/desktop/src-tauri/tauri.release-ci.json index 55a99462..d57cd002 100644 --- a/desktop/src-tauri/tauri.release-ci.json +++ b/desktop/src-tauri/tauri.release-ci.json @@ -1,5 +1,5 @@ { "bundle": { - "createUpdaterArtifacts": false + "createUpdaterArtifacts": true } } diff --git a/desktop/src/components/shared/UpdateChecker.tsx b/desktop/src/components/shared/UpdateChecker.tsx index 771acbff..9292cd8a 100644 --- a/desktop/src/components/shared/UpdateChecker.tsx +++ b/desktop/src/components/shared/UpdateChecker.tsx @@ -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(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 ( -
+

- {t('update.available', { version: update.version })} + {t('update.available', { version: availableVersion })}

- {update.downloading ? ( -
+ + {releaseNotes && ( +

+ {releaseNotes} +

+ )} + + {(status === 'downloading' || status === 'restarting') && ( +
-

{t('update.downloading')}

+ {statusText && ( +

+ {statusText} {status === 'downloading' ? `${progressPercent}%` : ''} +

+ )}
- ) : ( -
+ )} + + {error && ( +

+ {t('update.failed', { error })} +

+ )} + + {status === 'available' && ( +
+
+
+
+
{t('settings.about.updates')}
+
+ {t('settings.about.updatesDesc')} +
+
+ +
+ +
+
+
+
+ {t('settings.about.version')} +
+
+ {version || t('update.currentVersionUnknown')} +
+
+ + {availableVersion && ( +
+
+ {t('update.availableLabel')} +
+
+ {availableVersion} +
+
+ )} +
+ +

+ {updateDescription} +

+ + {checkedAtText && ( +

+ {t('update.checkedAt', { time: checkedAtText })} +

+ )} + + {(updateStatus === 'downloading' || updateStatus === 'restarting') && ( +
+
+
+
+
+ )} + + {releaseNotes && availableVersion && ( +
+
+ {t('update.releaseNotes')} +
+

+ {releaseNotes} +

+
+ )} + + {availableVersion && ( +
+ +
+ )} +
+
+ {/* Divider */}
diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts new file mode 100644 index 00000000..c5eb22e9 --- /dev/null +++ b/desktop/src/stores/updateStore.test.ts @@ -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) + }) +}) diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts new file mode 100644 index 00000000..90d5efa1 --- /dev/null +++ b/desktop/src/stores/updateStore.ts @@ -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 + checkForUpdates: (options?: CheckOptions) => Promise + installUpdate: () => Promise + dismissPrompt: () => void +} + +let pendingUpdate: Update | null = null +let startupCheckPromise: Promise | 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((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, + })) + }, +}))