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 (
) : null}
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);
});
});