import { useEffect, useRef, useState } from 'react'; import { getVersion } from '@tauri-apps/api/app'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { Download, ExternalLink, Info, RefreshCw, RotateCcw, Trash2, } from 'lucide-react'; import { useCoreRuntime } from '../coreRuntime'; import { useCoreUpdate } from '../coreUpdate'; import { useI18n } from '../i18n'; import { useAppUpdate } from '../appUpdate'; import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice'; import { createVersionManagementVisitTracker } from '../services/versionManagementVisits'; import { AppReleaseNotes } from '../components/AppReleaseNotes'; export type CoreInstallResult = { version: string; assetName: string; installDir: string; binaryPath: string | null; }; export type CoreInstallTask = { running: boolean; cancellable: boolean; phase: string; downloaded: number; total: number | null; percent: number | null; message: string | null; result: CoreInstallResult | null; }; export type VersionSourceSettings = { source: VersionDownloadSource; gitcodeAvailable: boolean; customMirrors: string[]; }; export type VersionDownloadSource = string; function downloadSourceLabel(source: VersionDownloadSource, t: ReturnType['t']) { const keys: Record[0]> = { github: 'kernel.versions.source.github', gitcode: 'kernel.versions.source.gitcode', 'gh-proxy': 'kernel.versions.source.ghProxy', 'gh-fast': 'kernel.versions.source.ghFast', }; if (source.startsWith('custom:')) { const url = source.slice('custom:'.length); try { return `${t('kernel.versions.source.custom')} · ${new URL(url).host}`; } catch { return t('kernel.versions.source.custom'); } } return t(keys[source] ?? 'kernel.versions.source.github'); } export type MessageType = 'info' | 'success' | 'error'; const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest'; export const DEFAULT_VERSION_DOWNLOAD_SOURCE = 'github'; const recordVersionManagementVisit = createVersionManagementVisitTracker(); export function displayAppVersion(version: string) { const resolvedVersion = version.trim(); return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`; } export function VersionManagementPage() { const { t } = useI18n(); const { info: appUpdate, error: appUpdateError, checking: checkingAppUpdate, task: appUpdateTask, check: checkAppUpdate, requestInstall: requestAppUpdate, } = useAppUpdate(); const { status: coreStatus, refreshStatus, } = useCoreRuntime(); const { latest, error: latestError, checking: checkingLatest, hasUpdate: coreHasUpdate, check: checkLatest, reset: resetLatest, } = useCoreUpdate(); const [installedAppVersion, setInstalledAppVersion] = useState(''); const [installing, setInstalling] = useState(false); const [progress, setProgress] = useState(null); const [installDialogOpen, setInstallDialogOpen] = useState(false); const [confirmUpdateOpen, setConfirmUpdateOpen] = useState(false); const [cancellingInstall, setCancellingInstall] = useState(false); const [versionSource, setVersionSource] = useState(null); const [versionSourceSaving, setVersionSourceSaving] = useState(false); const [versionSourceError, setVersionSourceError] = useState(''); const [customMirrorDraft, setCustomMirrorDraft] = useState(''); const [customMirrorDialogOpen, setCustomMirrorDialogOpen] = useState(false); const feedback = useAppNotice(); const { showNotice } = feedback; const installDialogRef = useRef(null); const customMirrorInputRef = useRef(null); const completedInstallKeyRef = useRef(''); const manualInstallInProgressRef = useRef(false); const pageVisitRef = useRef({}); const showInstallCompletedNotice = (result: CoreInstallResult, message?: string | null) => { const key = `${result.version}\u0000${result.assetName}\u0000${result.binaryPath ?? ''}`; if (completedInstallKeyRef.current === key) return; completedInstallKeyRef.current = key; showNotice(message || t('kernel.install.completed', { version: result.version }), 'success'); }; const applyInstallTask = ( task: CoreInstallTask, showFinishedDialog = true, showCompletionNotice = true, ) => { if (!task.running && !task.message && !task.result) { setProgress(null); setInstalling(false); setCancellingInstall(false); return; } setInstalling(task.running); if (!task.running) { setCancellingInstall(false); } if (showCompletionNotice && (task.running || showFinishedDialog)) { setProgress(task); setInstallDialogOpen(true); } else { setProgress(null); setInstallDialogOpen(false); } if (task.result) { if (showCompletionNotice) { showInstallCompletedNotice(task.result, task.message); } setInstallDialogOpen(false); setProgress(null); setCancellingInstall(false); void refreshStatus(); return; } if (task.message && !task.running) { showNotice(task.message, task.phase === '安装失败' ? 'error' : 'info'); } }; const loadVersionSourceSettings = async () => { try { const settings = await invoke('get_version_source_settings'); setVersionSource(settings); setVersionSourceError(''); } catch (error) { setVersionSourceError(String(error)); } }; const loadInstallTask = async () => { try { const task = await invoke('get_core_install_task'); applyInstallTask(task, false, false); } catch {} }; const updateVersionSource = async (source: VersionDownloadSource) => { setVersionSourceSaving(true); setVersionSourceError(''); try { const settings = await invoke('set_download_source', { source }); setVersionSource(settings); resetLatest(); showNotice({ key: 'kernel.versions.sourceSwitched', variables: { source: downloadSourceLabel(settings.source, t), } }, 'info'); } catch (error) { await loadVersionSourceSettings(); setVersionSourceError(t('kernel.versions.gitcodeSaveFailed', { error: String(error) })); } finally { setVersionSourceSaving(false); } }; const addCustomMirror = async () => { const url = customMirrorDraft.trim(); if (!url) return; setVersionSourceSaving(true); setVersionSourceError(''); try { const settings = await invoke('add_custom_download_mirror', { url }); setVersionSource(settings); setCustomMirrorDraft(''); setCustomMirrorDialogOpen(false); resetLatest(); showNotice({ key: 'kernel.versions.customMirrorAdded' }, 'success'); } catch (error) { const message = t('kernel.versions.customMirrorAddFailed', { error: String(error) }); setVersionSourceError(message); } finally { setVersionSourceSaving(false); } }; const removeCustomMirror = async (url: string) => { const wasSelected = versionSource?.source === `custom:${url}`; setVersionSourceSaving(true); setVersionSourceError(''); try { const settings = await invoke('remove_custom_download_mirror', { url, }); setVersionSource(settings); showNotice({ key: 'kernel.versions.customMirrorRemoved' }, 'success'); if (wasSelected) { resetLatest(); } } catch (error) { const message = t('kernel.versions.customMirrorRemoveFailed', { error: String(error) }); setVersionSourceError(message); } finally { setVersionSourceSaving(false); } }; const installVersion = async (version: string) => { completedInstallKeyRef.current = ''; manualInstallInProgressRef.current = true; setInstalling(true); setCancellingInstall(false); setInstallDialogOpen(true); setProgress({ running: true, cancellable: true, phase: '准备下载', downloaded: 0, total: null, percent: null, message: null, result: null, }); try { const result = await invoke('install_core_version', { version }); showInstallCompletedNotice(result, t('kernel.install.completed', { version: result.version })); manualInstallInProgressRef.current = false; setProgress({ running: false, cancellable: false, phase: '安装完成', downloaded: 1, total: 1, percent: 100, message: t('kernel.install.completed', { version: result.version }), result, }); setInstallDialogOpen(false); setProgress(null); setCancellingInstall(false); await refreshStatus(); } catch (error) { manualInstallInProgressRef.current = false; const errorMessage = String(error); showNotice(errorMessage, errorMessage.includes('取消') ? 'info' : 'error'); setProgress((current) => ({ running: false, cancellable: false, phase: errorMessage.includes('取消') ? '已取消' : '安装失败', downloaded: current?.downloaded ?? 0, total: current?.total ?? null, percent: current?.percent ?? null, message: errorMessage, result: null, })); } finally { setInstalling(false); } }; const cancelInstall = async () => { if (cancellingInstall || !progress?.running || !progress.cancellable) { return; } setCancellingInstall(true); try { await invoke('cancel_core_install'); } catch (error) { setCancellingInstall(false); showNotice(String(error), 'error'); } }; const closeInstallDialog = () => { if (installing || progress?.running) { return; } setInstallDialogOpen(false); setProgress(null); setCancellingInstall(false); }; const openAppRelease = async (url = appUpdate?.releaseUrl || APP_RELEASE_URL) => { try { await invoke('open_external_url', { url }); } catch (error) { showNotice({ key: 'kernel.error.openUpdate', variables: { error: String(error) } }, 'error'); } }; useEffect(() => { if (!recordVersionManagementVisit(pageVisitRef.current)) return; if (!checkingAppUpdate && !appUpdateTask.running) { void checkAppUpdate(); } if (!checkingLatest) { void checkLatest(); } }, [appUpdateTask.running, checkAppUpdate, checkLatest, checkingAppUpdate, checkingLatest]); useEffect(() => { let disposed = false; let unlisten: (() => void) | null = null; let unlistenConfig: (() => void) | null = null; let unlistenVersionSource: (() => void) | null = null; listen('core-install-progress', (event) => { const showTaskUi = manualInstallInProgressRef.current; applyInstallTask(event.payload, showTaskUi, showTaskUi); if (!event.payload.running) { manualInstallInProgressRef.current = false; } }) .then((unlistenProgress) => { if (disposed) unlistenProgress(); else unlisten = unlistenProgress; }) .catch(() => undefined); void listen('config-files-changed', () => { if (disposed) return; void loadVersionSourceSettings(); void refreshStatus(); }).then((stop) => { if (disposed) stop(); else unlistenConfig = stop; }); void listen('version-download-source-changed', (event) => { if (disposed) return; setVersionSource(event.payload); setVersionSourceError(''); showNotice({ key: 'kernel.versions.sourceAutoSwitched', variables: { source: downloadSourceLabel(event.payload.source, t), } }, 'info'); }).then((stop) => { if (disposed) stop(); else unlistenVersionSource = stop; }); loadInstallTask(); void loadVersionSourceSettings(); void getVersion() .then((version) => { if (!disposed) setInstalledAppVersion(version); }) .catch(() => undefined); return () => { disposed = true; unlisten?.(); unlistenConfig?.(); unlistenVersionSource?.(); }; }, []); useEffect(() => { if (!installDialogOpen) return; const previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; installDialogRef.current?.focus(); const preventEscapeClose = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); } }; document.addEventListener('keydown', preventEscapeClose); return () => { document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', preventEscapeClose); }; }, [installDialogOpen]); useEffect(() => { if (!customMirrorDialogOpen) return; const previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; window.setTimeout(() => customMirrorInputRef.current?.focus(), 0); const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !versionSourceSaving) { setCustomMirrorDialogOpen(false); } }; document.addEventListener('keydown', closeOnEscape); return () => { document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', closeOnEscape); }; }, [customMirrorDialogOpen, versionSourceSaving]); const latestVersion = latest?.version ?? ''; const currentVersion = coreStatus?.currentVersion ?? ''; const coreInstalled = Boolean(coreStatus?.installed); const coreProcessBusy = Boolean(coreStatus?.starting); const busy = checkingLatest || installing || coreProcessBusy; const installDisabled = busy || installing; const resolvedAppVersion = appUpdate?.currentVersion || installedAppVersion; const currentAppVersion = resolvedAppVersion ? displayAppVersion(resolvedAppVersion) : t('common.detecting'); const latestAppVersion = appUpdate?.latestVersion ? displayAppVersion(appUpdate.latestVersion) : ''; const appHasUpdate = Boolean(appUpdate?.updateAvailable); const appVersionStatusLabel: string | null = appUpdateTask.running ? t(`appUpdate.phase.${appUpdateTask.phase}` as Parameters[0]) : appUpdateError ? t('kernel.update.failed') : appHasUpdate ? t('appUpdate.available', { version: latestAppVersion }) : checkingAppUpdate || !appUpdate ? t('appUpdate.phase.checking') : null; const appVersionStatusTone = appUpdateError ? 'error' : appUpdateTask.running || checkingAppUpdate || !appUpdate ? 'info' : 'update'; const coreVersionStatusTone = installing || progress?.running ? 'info' : latestError ? 'error' : coreHasUpdate ? 'update' : 'neutral'; const coreVersionStatusLabel: string | null = installing || progress?.running ? (cancellingInstall ? t('kernel.install.cancelling') : progress?.phase ? localizeInstallPhase(progress.phase, t) : t('kernel.install.inProgress')) : latestError ? t('kernel.update.failed') : coreHasUpdate ? t('kernel.update.available') : !coreInstalled ? t('kernel.status.notInstalled') : null; const computedPercent = progress?.percent ?? (progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null); const progressKnown = computedPercent !== null; const progressPercent = clampPercent(computedPercent ?? 0); const progressText = progress ? progress.phase === '安装完成' ? t('kernel.progress.completed') : progress.phase === '解压中' ? t('kernel.progress.extracting') : progress.total ? `${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}` : progress.downloaded > 0 ? formatBytes(progress.downloaded) : t('kernel.progress.waiting') : ''; const installDialogTone: MessageType = progress?.result ? 'success' : progress?.phase === '安装失败' ? 'error' : 'info'; const installDialogTitle = progress?.running || installing ? cancellingInstall ? t('kernel.install.titleCancelling') : t('kernel.install.titleInstalling') : progress?.result ? t('kernel.install.titleCompleted') : progress?.phase === '已取消' ? t('kernel.install.titleCancelled') : t('kernel.install.titleFailed'); const installDialogMessage = cancellingInstall ? t('kernel.install.waitingStop') : progress?.message || (installing ? t('kernel.install.taskRunning') : ''); const installDialogAction = installing || progress?.running ? cancellingInstall ? t('kernel.install.cancellingShort') : progress?.cancellable ? t('kernel.install.cancel') : t('common.processing') : t('common.close'); const installDialogActionDisabled = (installing || progress?.running) && (cancellingInstall || !progress?.cancellable); return (
setVersionSourceError('')} />
{t('kernel.versions.downloadSource')} {t('kernel.versions.downloadSourceHint')} {versionSource?.gitcodeAvailable === false ? ( {t('kernel.versions.gitcodeUnavailable')} ) : null}

{t('kernel.versions.appCardTitle')}

{appVersionStatusLabel ? ( {appVersionStatusLabel} ) : null}
{t('appUpdate.current')}
{currentAppVersion}
{t('appUpdate.latest')}
{appUpdate ? (latestAppVersion || currentAppVersion) : (checkingAppUpdate ? t('appUpdate.checking') : t('common.detecting'))}
{!appUpdate?.autoUpdateSupported ? (
{t('appUpdate.manualFallback')}
) : null}
{appHasUpdate && appUpdate?.autoUpdateSupported ? ( ) : null}

{t('kernel.versions.coreCardTitle')}

{coreVersionStatusLabel ? ( {coreVersionStatusLabel} ) : null}
{t('kernel.versions.current')}
{currentVersion || t('kernel.status.notInstalled')}
{t('kernel.versions.latest')}
{checkingLatest ? t('kernel.update.checking') : (latestVersion || (latestError ? t('common.detectionFailed') : t('kernel.update.notChecked')))}
{latestError ? ( ) : null}
{customMirrorDialogOpen ? (
{ event.preventDefault(); void addCustomMirror(); }} >
{t('kernel.versions.downloadSource')}

{t('kernel.versions.customMirrorDialogTitle')}

{t('kernel.versions.customMirrorDialogDescription')}

setCustomMirrorDraft(event.currentTarget.value)} /> {versionSource?.customMirrors.length ? (
{t('kernel.versions.customMirrorSaved')} {versionSource.customMirrors.map((url) => (
{url}
))}
) : null}
) : null} {confirmUpdateOpen ? (
{t('kernel.dialog.install')}

{t('kernel.versions.confirmUpdateTitle')}

{t('kernel.versions.stopAndConfirmDescription', { version: latestVersion })}

) : null} {installDialogOpen && progress ? (
{t('kernel.dialog.install')}

{installDialogTitle}

{t('kernel.dialog.phase')} {cancellingInstall ? t('kernel.install.cancellingShort') : localizeInstallPhase(progress.phase, t)}
{progressKnown ? `${progressPercent.toFixed(1)}%` : t('kernel.dialog.unknownProgress')} {progressText}
{installDialogMessage || ' '}
) : null}
); } function localizeInstallPhase( phase: string, t: ReturnType['t'], ) { const keys = { '准备下载': 'kernel.phase.preparingDownload', '下载中': 'kernel.phase.downloading', '解压中': 'kernel.phase.extracting', '准备内置内核': 'kernel.phase.preparingBundled', '安装完成': 'kernel.phase.completed', '安装失败': 'kernel.phase.failed', '已取消': 'kernel.phase.cancelled', } as const; const key = keys[phase as keyof typeof keys]; return key ? t(key) : phase; } function clampPercent(percent: number) { return Math.min(100, Math.max(0, percent)); } function formatBytes(bytes: number) { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } return `${(bytes / 1024 / 1024).toFixed(1)} MB`; }