fix(update): show notification when CPA core update is available

This commit is contained in:
lzt404 committed 2026-09-04 01:50:46 +08:00
1 parent bf6177e15a
commit 594ab43b6a
6 files changed
+197 -99

No files matched your search

+16 -4
View File
@@ -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 (
<AppUpdateProvider>
<CoreRuntimeProvider>
<AppContent />
<CoreUpdateProvider>
<AppContent />
</CoreUpdateProvider>
</CoreRuntimeProvider>
</AppUpdateProvider>
);
@@ -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<PageId>('home');
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [theme, setTheme] = useState<AppTheme>(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 (
<button
@@ -310,10 +322,10 @@ function AppContent() {
className={`nav-update-indicator ${updateIndicator}`}
title={updateIndicator === 'processing'
? t('appUpdate.progressTitle')
: t('appUpdate.badgeAvailable', { version: appUpdateInfo?.latestVersion ?? '' })}
: availableUpdateLabel}
aria-label={updateIndicator === 'processing'
? t('appUpdate.progressTitle')
: t('appUpdate.badgeAvailable', { version: appUpdateInfo?.latestVersion ?? '' })}
: availableUpdateLabel}
/>
) : null}
</button>
+3 -2
View File
@@ -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;
}
+147
View File
@@ -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<void>;
reset: () => void;
};
let latestAutoCheckStarted = false;
let cachedLatest: CoreLatest | null = null;
let cachedLatestError = '';
let latestCheckPromise: Promise<CoreLatest> | 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<CoreLatest>('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<CoreUpdateContextValue | null>(null);
export function CoreUpdateProvider({ children }: { children: ReactNode }) {
const { status } = useCoreRuntime();
const [latest, setLatest] = useState<CoreLatest | null>(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<CoreUpdateContextValue>(() => ({
latest,
error,
checking,
hasUpdate: coreUpdateAvailable(status?.currentVersion, latest?.version),
check,
reset,
}), [check, checking, error, latest, reset, status?.currentVersion]);
return <CoreUpdateContext.Provider value={value}>{children}</CoreUpdateContext.Provider>;
}
export function useCoreUpdate() {
const context = useContext(CoreUpdateContext);
if (!context) {
throw new Error('useCoreUpdate 必须在 CoreUpdateProvider 内使用');
}
return context;
}
+11 -83
View File
@@ -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<typeof
export type MessageType = 'info' | 'success' | 'error';
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
let latestAutoCheckStarted = false;
let cachedLatest: CoreLatest | null = null;
let cachedLatestError = '';
let latestCheckPromise: Promise<CoreLatest> | 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<CoreLatest>('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<CoreLatest | null>(cachedLatest);
const [latestError, setLatestError] = useState(cachedLatestError);
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
const [installing, setInstalling] = useState(false);
const [progress, setProgress] = useState<CoreInstallTask | null>(null);
@@ -151,7 +115,6 @@ export function VersionManagementPage() {
const installDialogRef = useRef<HTMLDivElement>(null);
const customMirrorInputRef = useRef<HTMLInputElement>(null);
const latestCheckEpochRef = useRef(0);
const toastTimerRef = useRef<number | null>(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<VersionSourceSettings>('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
+8 -6
View File
@@ -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();
});
});
+12 -4
View File
@@ -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);
});
});