fix(update): show notification when CPA core update is available
This commit is contained in:
1 parent
bf6177e15a
commit
594ab43b6a
6 files changed
+197
-99
No files matched your search
+16
-4
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user