From 594ab43b6a010e7947a03616a844bac3f81aba9a Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 4 Sep 2026 01:35:46 +0800 Subject: [PATCH] fix(update): show notification when CPA core update is available --- src/App.tsx | 20 +++- src/appUpdateModel.ts | 5 +- src/coreUpdate.tsx | 147 ++++++++++++++++++++++++++++ src/pages/VersionManagementPage.tsx | 94 +++--------------- tests/navigationAndUpdate.test.ts | 14 +-- tests/versionManagement.test.ts | 16 ++- 6 files changed, 197 insertions(+), 99 deletions(-) create mode 100644 src/coreUpdate.tsx diff --git a/src/App.tsx b/src/App.tsx index d46d780..eb173c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import appLogo from './assets/logo.jpg'; import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime'; +import { CoreUpdateProvider, useCoreUpdate } from './coreUpdate'; import { ConfigPanelPage } from './pages/ConfigPanel'; import { ApiAccessPage } from './pages/ApiAccessPage'; import { KernelPage } from './pages/Kernel'; @@ -113,7 +114,9 @@ function App() { return ( - + + + ); @@ -122,6 +125,7 @@ function App() { function AppContent() { const { locale, setLocale, t } = useI18n(); const { info: appUpdateInfo, hasUpdate, processing: appUpdateProcessing } = useAppUpdate(); + const { latest: coreLatest, hasUpdate: coreHasUpdate } = useCoreUpdate(); const [active, setActive] = useState('home'); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const [theme, setTheme] = useState(detectInitialTheme); @@ -135,6 +139,14 @@ function AppContent() { const ActivePage = activePage.component; const selectedLanguage = languageOptions.find((option) => option.value === locale) ?? languageOptions[0]; + const availableUpdateLabel = [ + hasUpdate + ? t('appUpdate.badgeAvailable', { version: appUpdateInfo?.latestVersion ?? '' }) + : '', + coreHasUpdate + ? `${t('kernel.versions.coreCardTitle')}: ${t('kernel.update.available')} ${coreLatest?.version ?? ''}`.trim() + : '', + ].filter(Boolean).join(' · '); useEffect(() => { saveTheme(theme); }, [theme]); @@ -287,7 +299,7 @@ function AppContent() { const Icon = page.icon; const locked = !canOpenAppPage(page.id, coreRunning); const updateIndicator = page.id === 'versions' - ? appUpdateIndicatorState(hasUpdate, appUpdateProcessing) + ? appUpdateIndicatorState(hasUpdate, coreHasUpdate, appUpdateProcessing) : null; return ( diff --git a/src/appUpdateModel.ts b/src/appUpdateModel.ts index ad142d9..a88827b 100644 --- a/src/appUpdateModel.ts +++ b/src/appUpdateModel.ts @@ -1,9 +1,10 @@ export type AppUpdateIndicatorState = 'available' | 'processing' | null; export function appUpdateIndicatorState( - hasUpdate: boolean, + appHasUpdate: boolean, + coreHasUpdate: boolean, processing: boolean, ): AppUpdateIndicatorState { if (processing) return 'processing'; - return hasUpdate ? 'available' : null; + return appHasUpdate || coreHasUpdate ? 'available' : null; } diff --git a/src/coreUpdate.tsx b/src/coreUpdate.tsx new file mode 100644 index 0000000..1962a31 --- /dev/null +++ b/src/coreUpdate.tsx @@ -0,0 +1,147 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useCoreRuntime } from './coreRuntime'; + +export type CoreLatest = { + version: string; + assetName: string; +}; + +type CoreUpdateContextValue = { + latest: CoreLatest | null; + error: string; + checking: boolean; + hasUpdate: boolean; + check: (force?: boolean) => Promise; + reset: () => void; +}; + +let latestAutoCheckStarted = false; +let cachedLatest: CoreLatest | null = null; +let cachedLatestError = ''; +let latestCheckPromise: Promise | null = null; +let latestRequestEpoch = 0; + +function normalizeVersion(version: string | null | undefined) { + return (version ?? '').trim().replace(/^v/i, ''); +} + +export function coreUpdateAvailable( + currentVersion: string | null | undefined, + latestVersion: string | null | undefined, +) { + const current = normalizeVersion(currentVersion); + const latest = normalizeVersion(latestVersion); + return Boolean(current && latest && current !== latest); +} + +export function requestLatestCore(force = false) { + if (!force && latestCheckPromise) { + return latestCheckPromise; + } + + const requestEpoch = ++latestRequestEpoch; + const request = invoke('check_latest_core') + .then((result) => { + if (requestEpoch === latestRequestEpoch) { + cachedLatest = result; + cachedLatestError = ''; + } + return result; + }) + .catch((error) => { + if (requestEpoch === latestRequestEpoch) { + cachedLatest = null; + cachedLatestError = String(error); + } + throw error; + }) + .finally(() => { + if (latestCheckPromise === request) { + latestCheckPromise = null; + } + }); + latestCheckPromise = request; + + return request; +} + +const CoreUpdateContext = createContext(null); + +export function CoreUpdateProvider({ children }: { children: ReactNode }) { + const { status } = useCoreRuntime(); + const [latest, setLatest] = useState(cachedLatest); + const [error, setError] = useState(cachedLatestError); + const [checking, setChecking] = useState(Boolean(latestCheckPromise)); + const checkEpochRef = useRef(0); + + const check = useCallback(async (force = false) => { + const checkEpoch = ++checkEpochRef.current; + setChecking(true); + setError(''); + + try { + const result = await requestLatestCore(force); + if (checkEpoch === checkEpochRef.current) { + setLatest(result); + } + } catch (nextError) { + if (checkEpoch === checkEpochRef.current) { + setLatest(null); + setError(String(nextError)); + } + } finally { + if (checkEpoch === checkEpochRef.current) { + setChecking(false); + } + } + }, []); + + const reset = useCallback(() => { + latestRequestEpoch += 1; + latestCheckPromise = null; + cachedLatest = null; + cachedLatestError = ''; + checkEpochRef.current += 1; + setLatest(null); + setError(''); + setChecking(false); + }, []); + + useEffect(() => { + if (!latestAutoCheckStarted) { + latestAutoCheckStarted = true; + void check(); + } else if (latestCheckPromise) { + void check(); + } + }, [check]); + + const value = useMemo(() => ({ + latest, + error, + checking, + hasUpdate: coreUpdateAvailable(status?.currentVersion, latest?.version), + check, + reset, + }), [check, checking, error, latest, reset, status?.currentVersion]); + + return {children}; +} + +export function useCoreUpdate() { + const context = useContext(CoreUpdateContext); + if (!context) { + throw new Error('useCoreUpdate 必须在 CoreUpdateProvider 内使用'); + } + return context; +} diff --git a/src/pages/VersionManagementPage.tsx b/src/pages/VersionManagementPage.tsx index 2255eda..ab7fe40 100644 --- a/src/pages/VersionManagementPage.tsx +++ b/src/pages/VersionManagementPage.tsx @@ -13,14 +13,10 @@ import { Trash2, } from 'lucide-react'; import { useCoreRuntime } from '../coreRuntime'; +import { useCoreUpdate } from '../coreUpdate'; import { useI18n } from '../i18n'; import { useAppUpdate } from '../appUpdate'; -export type CoreLatest = { - version: string; - assetName: string; -}; - export type CoreInstallResult = { version: string; assetName: string; @@ -68,48 +64,11 @@ function downloadSourceLabel(source: VersionDownloadSource, t: ReturnType | null = null; -let latestRequestEpoch = 0; - export function displayAppVersion(version: string) { const resolvedVersion = version.trim(); return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`; } -export function requestLatestCore(force = false) { - if (!force && latestCheckPromise) { - return latestCheckPromise; - } - - const requestEpoch = ++latestRequestEpoch; - const request = invoke('check_latest_core') - .then((result) => { - if (requestEpoch === latestRequestEpoch) { - cachedLatest = result; - cachedLatestError = ''; - } - return result; - }) - .catch((error) => { - if (requestEpoch === latestRequestEpoch) { - cachedLatest = null; - cachedLatestError = String(error); - } - throw error; - }) - .finally(() => { - if (latestCheckPromise === request) { - latestCheckPromise = null; - } - }); - latestCheckPromise = request; - - return request; -} - export function VersionManagementPage() { const { t } = useI18n(); const { @@ -126,11 +85,16 @@ export function VersionManagementPage() { statusError, refreshStatus, } = useCoreRuntime(); + const { + latest, + error: latestError, + checking: checkingLatest, + hasUpdate: coreHasUpdate, + check: checkLatest, + reset: resetLatest, + } = useCoreUpdate(); const [installedAppVersion, setInstalledAppVersion] = useState(''); - const [latest, setLatest] = useState(cachedLatest); - const [latestError, setLatestError] = useState(cachedLatestError); - const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise)); const [installing, setInstalling] = useState(false); const [progress, setProgress] = useState(null); @@ -151,7 +115,6 @@ export function VersionManagementPage() { const installDialogRef = useRef(null); const customMirrorInputRef = useRef(null); - const latestCheckEpochRef = useRef(0); const toastTimerRef = useRef(null); const completedInstallKeyRef = useRef(''); const manualInstallInProgressRef = useRef(false); @@ -232,37 +195,13 @@ export function VersionManagementPage() { } catch {} }; - const checkLatest = async (force = false) => { - const checkEpoch = ++latestCheckEpochRef.current; - setCheckingLatest(true); - setLatestError(''); - - try { - const result = await requestLatestCore(force); - if (checkEpoch === latestCheckEpochRef.current) { - setLatest(result); - } - } catch (error) { - if (checkEpoch === latestCheckEpochRef.current) { - setLatest(null); - setLatestError(String(error)); - } - } finally { - if (checkEpoch === latestCheckEpochRef.current) { - setCheckingLatest(false); - } - } - }; - const updateVersionSource = async (source: VersionDownloadSource) => { setVersionSourceSaving(true); setVersionSourceError(''); try { const settings = await invoke('set_download_source', { source }); setVersionSource(settings); - cachedLatest = null; - cachedLatestError = ''; - setLatest(null); + resetLatest(); showToast(t('kernel.versions.sourceSwitched', { source: downloadSourceLabel(settings.source, t), }), 'info'); @@ -288,9 +227,7 @@ export function VersionManagementPage() { setVersionSource(settings); setCustomMirrorDraft(''); setCustomMirrorDialogOpen(false); - cachedLatest = null; - cachedLatestError = ''; - setLatest(null); + resetLatest(); showToast(t('kernel.versions.customMirrorAdded'), 'success'); setVersionSourceSaving(false); await checkAppUpdate(); @@ -462,13 +399,6 @@ export function VersionManagementPage() { }) .catch(() => undefined); - if (!latestAutoCheckStarted) { - latestAutoCheckStarted = true; - void checkLatest(); - } else if (latestCheckPromise) { - void checkLatest(); - } - return () => { disposed = true; unlisten?.(); @@ -547,8 +477,6 @@ export function VersionManagementPage() { ? 'info' : 'update'; - const coreHasUpdate = Boolean(latestVersion && currentVersion && currentVersion !== latestVersion); - const coreVersionStatusTone = installing || progress?.running ? 'info' : latestError diff --git a/tests/navigationAndUpdate.test.ts b/tests/navigationAndUpdate.test.ts index 3524145..33857d1 100644 --- a/tests/navigationAndUpdate.test.ts +++ b/tests/navigationAndUpdate.test.ts @@ -35,14 +35,16 @@ describe('OAuth 子页面导航', () => { }); }); -describe('软件更新导航提示点', () => { - test('有新版显示橙点,处理中的蓝点优先', () => { - expect(appUpdateIndicatorState(true, false)).toBe('available'); - expect(appUpdateIndicatorState(true, true)).toBe('processing'); - expect(appUpdateIndicatorState(false, true)).toBe('processing'); +describe('软件与内核更新导航提示点', () => { + test('任一组件有新版都显示橙点,处理中的蓝点优先', () => { + expect(appUpdateIndicatorState(true, false, false)).toBe('available'); + expect(appUpdateIndicatorState(false, true, false)).toBe('available'); + expect(appUpdateIndicatorState(true, true, false)).toBe('available'); + expect(appUpdateIndicatorState(true, false, true)).toBe('processing'); + expect(appUpdateIndicatorState(false, true, true)).toBe('processing'); }); test('最新版或检查失败都不显示提示点', () => { - expect(appUpdateIndicatorState(false, false)).toBeNull(); + expect(appUpdateIndicatorState(false, false, false)).toBeNull(); }); }); diff --git a/tests/versionManagement.test.ts b/tests/versionManagement.test.ts index a0fb876..d3388ed 100644 --- a/tests/versionManagement.test.ts +++ b/tests/versionManagement.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { displayAppVersion } from '../src/pages/VersionManagementPage'; +import { coreUpdateAvailable } from '../src/coreUpdate'; import { appUpdateIndicatorState } from '../src/appUpdateModel'; describe('VersionManagement helper functions', () => { @@ -11,9 +12,16 @@ describe('VersionManagement helper functions', () => { }); it('resolves update indicators based on availability and processing state', () => { - expect(appUpdateIndicatorState(true, false)).toBe('available'); - expect(appUpdateIndicatorState(true, true)).toBe('processing'); - expect(appUpdateIndicatorState(false, true)).toBe('processing'); - expect(appUpdateIndicatorState(false, false)).toBeNull(); + expect(appUpdateIndicatorState(true, false, false)).toBe('available'); + expect(appUpdateIndicatorState(false, true, false)).toBe('available'); + expect(appUpdateIndicatorState(true, true, true)).toBe('processing'); + expect(appUpdateIndicatorState(false, false, false)).toBeNull(); + }); + + it('compares installed and latest core versions consistently', () => { + expect(coreUpdateAvailable('v6.6.0', '6.6.0')).toBe(false); + expect(coreUpdateAvailable('6.5.0', 'v6.6.0')).toBe(true); + expect(coreUpdateAvailable(null, '6.6.0')).toBe(false); + expect(coreUpdateAvailable('6.5.0', '')).toBe(false); }); });