/* eslint-disable @eslint-react/set-state-in-effect */ import { AccessTimeRounded, ArrowDropDown, ChevronRight, NetworkCheckRounded, WifiOff as SignalError, SignalWifi3Bar as SignalGood, SignalWifi2Bar as SignalMedium, SignalWifi0Bar as SignalNone, SignalWifi4Bar as SignalStrong, SignalWifi1Bar as SignalWeak, SortByAlphaRounded, SortRounded, } from '@mui/icons-material' import { Box, Button, ButtonBase, Chip, ClickAwayListener, IconButton, MenuItem, MenuList, Paper, Popper, type PopperProps, Tooltip, Typography, alpha, useTheme, } from '@mui/material' import { useLockFn } from 'ahooks' import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import { EnhancedCard } from '@/components/home/enhanced-card' import type { ProxySortType } from '@/components/proxy/use-filter-sort' import { useGroupDelays } from '@/hooks/use-group-delays' import { useProfiles } from '@/hooks/use-profiles' import { useProxySelection } from '@/hooks/use-proxy-selection' import { useVerge } from '@/hooks/use-verge' import { useAppRefreshers, useClashConfigData, useCoreDataStatus, useProxiesData, } from '@/providers/app-data-context' import { syncTrayProxySelection } from '@/services/cmds' import delayManager from '@/services/delay' import { mutate } from '@/services/mutate' import { profileTestUrls } from '@/services/proxy-test-url' import { findCurrentGroupMember, getRecord, isInteractableMember, memberDetails, resolveMember, type ProxyGroupView, type ResolvedProxyMember, } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' import { compareByDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group' const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type' const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5 const AUTO_CHECK_INITIAL_DELAY_MS = 100 const PROXY_MENU_MAX_HEIGHT = 500 const PROXY_MENU_VIEWPORT_MARGIN = 8 const proxyMenuModifiers: PopperProps['modifiers'] = [ { name: 'menuSize', enabled: true, phase: 'beforeRead', fn: ({ state }) => { const popper = state.elements.popper const anchor = state.elements.reference.getBoundingClientRect() const viewportHeight = popper.ownerDocument.documentElement.clientHeight // Use the larger side before flip runs so sizing cannot alternate with placement. const availableHeight = Math.max(anchor.top, viewportHeight - anchor.bottom) - PROXY_MENU_VIEWPORT_MARGIN popper.style.width = `${anchor.width}px` popper.style.setProperty( '--proxy-menu-max-height', `${Math.max(0, Math.min(PROXY_MENU_MAX_HEIGHT, availableHeight))}px`, ) state.rects.popper.width = popper.offsetWidth state.rects.popper.height = popper.offsetHeight }, }, { name: 'showMenu', enabled: true, phase: 'beforeWrite', requires: ['computeStyles'], fn: ({ state }) => { state.styles.popper.visibility = 'visible' }, }, ] interface ProxyOption { memberIndex: number member: ResolvedProxyMember value: string name: string disabled: boolean } type OpenSelect = 'group' | 'proxy' | null function convertDelayColor( delayValue: number, ): 'success' | 'warning' | 'error' | 'primary' | 'default' { const colorStr = delayManager.formatDelayColor(delayValue) if (!colorStr) return 'default' const mainColor = colorStr.split('.')[0] switch (mainColor) { case 'success': return 'success' case 'warning': return 'warning' case 'error': return 'error' case 'primary': return 'primary' default: return 'default' } } function getSignalIcon( delay: number, translate: (key: string) => string, ): { icon: React.ReactElement text: string color: string } { if (delay === -2) return { icon: , text: translate('home.components.currentProxy.status.testing'), color: 'text.secondary', } if (delay === -1) return { icon: , text: translate('home.components.currentProxy.status.untested'), color: 'text.secondary', } if (delay > 1e5) return { icon: , text: translate('home.components.currentProxy.status.error'), color: 'error.main', } if (delay === 0 || delay >= 10000) return { icon: , text: translate('home.components.currentProxy.status.timeout'), color: 'error.main', } if (delay >= 500) return { icon: , text: translate('home.components.currentProxy.status.latencyHigh'), color: 'error.main', } if (delay >= 300) return { icon: , text: translate('home.components.currentProxy.status.latencyMedium'), color: 'warning.main', } if (delay >= 200) return { icon: , text: translate('home.components.currentProxy.status.latencyGood'), color: 'info.main', } return { icon: , text: translate('home.components.currentProxy.status.latencyExcellent'), color: 'success.main', } } const optionValue = (memberIndex: number, member: ResolvedProxyMember) => `${memberIndex}:${ member.kind === 'node' ? member.node.recordId : member.ref.name }` const sortProxyOptions = ( options: ProxyOption[], sortType: ProxySortType, delayOf: (member: ResolvedProxyMember) => number, latencyTimeout?: number, ) => { if (sortType === 0) return options if (sortType === 2) { return [...options].sort((a, b) => a.name.localeCompare(b.name)) } const effectiveTimeout = typeof latencyTimeout === 'number' && latencyTimeout > 0 ? latencyTimeout : DEFAULT_DELAY_TIMEOUT return options .map((option) => ({ option, delay: delayOf(option.member) })) .sort((a, b) => { const byDelay = compareByDelay(a.delay, b.delay, effectiveTimeout) return byDelay || a.option.name.localeCompare(b.option.name) }) .map(({ option }) => option) } interface PersistentSelectProps { label: string displayValue: React.ReactNode open: boolean disabled: boolean keepOpenRef: React.RefObject onOpenChange: (open: boolean) => void renderOptions: () => React.ReactNode } const PersistentSelect = ({ label, displayValue, open, disabled, keepOpenRef, onOpenChange, renderOptions, }: PersistentSelectProps) => { const id = useId() const labelId = `${id}-label` const valueId = `${id}-value` const listboxId = `${id}-listbox` const anchorRef = useRef(null) const listRef = useRef(null) const popperOptions = useMemo( () => ({ onFirstUpdate: () => { const list = listRef.current const paper = list?.parentElement const item = list?.querySelector( '[aria-selected="true"]:not([aria-disabled="true"])', ) ?? list?.querySelector( '[role="option"]:not([aria-disabled="true"])', ) if (!item || !paper) return // Focusing before positioning can scroll the page instead of just the menu. item.focus({ preventScroll: true }) paper.scrollTop += item.getBoundingClientRect().top - paper.getBoundingClientRect().top - (paper.clientHeight - item.offsetHeight) / 2 }, }), [], ) useEffect(() => { if (!open) return const closeFromKeyboard = (event: KeyboardEvent) => { if (event.key === 'Tab') { onOpenChange(false) } else if (event.key === 'Escape') { onOpenChange(false) anchorRef.current?.focus({ preventScroll: true }) } } document.addEventListener('keydown', closeFromKeyboard) return () => document.removeEventListener('keydown', closeFromKeyboard) }, [onOpenChange, open]) return ( { if (!open) return const target = event.target if (target instanceof Node && keepOpenRef.current?.contains(target)) { return } onOpenChange(false) }} > alpha(theme.palette.text.primary, disabled ? 0.12 : 0.23), }, ...(!disabled && { '&:hover fieldset': { borderColor: 'text.primary' }, '&:focus-within fieldset': { borderColor: 'primary.main', borderWidth: 2, }, '&:focus-within legend': { color: 'primary.main' }, }), }} > onOpenChange(!open)} onKeyDown={(event) => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() onOpenChange(true) } }} sx={{ width: '100%', height: 40, px: 1.75, borderRadius: 'inherit', justifyContent: 'space-between', textAlign: 'left', color: disabled ? 'text.disabled' : 'text.primary', }} > {displayValue} {label} theme.zIndex.modal, }} > {open && renderOptions()} ) } interface PersistentProxySelectProps extends Omit { value: string selectedName: string groupName: string fixed?: string | null selectedDelay: number options: ProxyOption[] onChange: (value: string) => void } const PersistentProxySelect = ({ groupName, fixed, selectedName, selectedDelay, options, value, onChange, ...props }: PersistentProxySelectProps) => { const fixedProxyInUsed = selectedName === fixed return ( {selectedName} {fixedProxyInUsed && ( 📌 )} } renderOptions={() => options.map((option) => { const selected = option.value === value const isFixed = option.name === fixed const delay = option.disabled ? -1 : delayManager.getDelayFix(option.member, groupName) return ( { if (!option.disabled) onChange(option.value) }} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', pr: 1, }} > {isFixed && ( 📌 )} {option.name} {!option.disabled && ( )} ) }) } /> ) } export const CurrentProxyCard = () => { const { t } = useTranslation() const navigate = useNavigate() const theme = useTheme() const { proxyView } = useProxiesData() const { clashConfig } = useClashConfigData() const { refreshProxy } = useAppRefreshers() const { isCoreDataPending } = useCoreDataStatus() const { verge } = useVerge() const { current: currentProfile } = useProfiles() const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false const defaultLatencyTimeout = verge?.default_latency_timeout const autoDelayIntervalMs = useMemo(() => { const rawInterval = verge?.auto_delay_detection_interval_minutes const intervalMinutes = typeof rawInterval === 'number' && rawInterval > 0 ? rawInterval : AUTO_CHECK_DEFAULT_INTERVAL_MINUTES return Math.max(1, Math.round(intervalMinutes)) * 60 * 1000 }, [verge?.auto_delay_detection_interval_minutes]) const currentProfileId = currentProfile?.uid || null const getProfileStorageKey = useCallback( (baseKey: string) => currentProfileId ? `${baseKey}:${currentProfileId}` : baseKey, [currentProfileId], ) const readProfileScopedItem = useCallback( (baseKey: string) => { if (typeof window === 'undefined') return null const profileKey = getProfileStorageKey(baseKey) const profileValue = localStorage.getItem(profileKey) if (profileValue != null) { return profileValue } if (profileKey !== baseKey) { const legacyValue = localStorage.getItem(baseKey) if (legacyValue != null) { localStorage.removeItem(baseKey) localStorage.setItem(profileKey, legacyValue) return legacyValue } } return null }, [getProfileStorageKey], ) const writeProfileScopedItem = useCallback( (baseKey: string, value: string) => { if (typeof window === 'undefined') return const profileKey = getProfileStorageKey(baseKey) localStorage.setItem(profileKey, value) if (profileKey !== baseKey) { localStorage.removeItem(baseKey) } }, [getProfileStorageKey], ) const { handleSelectChange } = useProxySelection({ onSuccess: () => { refreshProxy() }, onError: (error) => { console.error('代理切换失败', error) refreshProxy() }, }) const mode = clashConfig?.mode?.toLowerCase() || 'rule' const isGlobalMode = mode === 'global' const isDirectMode = mode === 'direct' const [sortType, setSortType] = useState(() => { const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE) return savedSortType ? (Number(savedSortType) as ProxySortType) : 0 }) const [selectedGroupName, setSelectedGroupName] = useState('') const [openSelect, setOpenSelect] = useState(null) useEffect(() => { setOpenSelect(null) }, [mode]) const delayButtonRef = useRef(null) const delays = useGroupDelays(selectedGroupName || null) const autoCheckInProgressRef = useRef(false) const latestTimeoutRef = useRef( verge?.default_latency_timeout || 10000, ) const latestProxyMemberRef = useRef(null) useEffect(() => { latestTimeoutRef.current = verge?.default_latency_timeout || 10000 }, [verge?.default_latency_timeout]) const selectableGroups = useMemo(() => { if (!proxyView) return [] return proxyView.groups.filter( (group) => !group.hidden && (group.type === 'Selector' || group.type === 'URLTest'), ) }, [proxyView]) const selectedGroup = useMemo(() => { if (!proxyView || isDirectMode) return null if (isGlobalMode) return proxyView.global return ( selectableGroups.find(({ name }) => name === selectedGroupName) ?? null ) }, [ isDirectMode, isGlobalMode, proxyView, selectableGroups, selectedGroupName, ]) const optionsForGroup = useCallback( (group: ProxyGroupView | null): ProxyOption[] => proxyView && group ? group.members.map((memberRef, memberIndex) => { const member = resolveMember(proxyView, memberRef) return { memberIndex, member, value: optionValue(memberIndex, member), name: member.ref.name, disabled: !isInteractableMember(member), } }) : [], [proxyView], ) useEffect(() => { if (!selectedGroup) return const customUrl = currentProfile ? profileTestUrls(currentProfile)[selectedGroup.name] : undefined delayManager.setUrl( selectedGroup.name, customUrl?.trim() || selectedGroup.testUrl?.trim() || verge?.default_latency_test?.trim() || 'http://cp.cloudflare.com/generate_204', ) }, [selectedGroup, currentProfile, verge?.default_latency_test]) const unsortedProxyOptions = useMemo( () => optionsForGroup(selectedGroup), [optionsForGroup, selectedGroup], ) useEffect(() => { if (!proxyView) return if (isDirectMode) { setSelectedGroupName('DIRECT') return } if (isGlobalMode) { setSelectedGroupName(proxyView.global?.name ?? 'GLOBAL') return } const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP) const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择'] const primaryGroup = selectableGroups.find((group) => primaryKeywords.some((keyword) => group.name.toLowerCase().includes(keyword.toLowerCase()), ), ) ?? selectableGroups[0] const nextGroup = selectableGroups.some( ({ name }) => name === selectedGroupName, ) ? selectedGroupName : selectableGroups.some(({ name }) => name === savedGroup) ? savedGroup! : (primaryGroup?.name ?? '') if (nextGroup !== selectedGroupName) { setSelectedGroupName(nextGroup) if (nextGroup) writeProfileScopedItem(STORAGE_KEY_GROUP, nextGroup) } }, [ isDirectMode, isGlobalMode, proxyView, readProfileScopedItem, selectableGroups, selectedGroupName, writeProfileScopedItem, ]) const currentOption = useMemo(() => { if (!proxyView) return undefined if (isDirectMode) { const node = proxyView.direct == null ? undefined : getRecord(proxyView, proxyView.direct) return node ? ({ memberIndex: 0, member: { kind: 'node', ref: { kind: 'node', name: node.name, recordId: node.recordId }, node, }, } satisfies Pick) : undefined } return selectedGroup ? findCurrentGroupMember(proxyView, selectedGroup) : undefined }, [isDirectMode, proxyView, selectedGroup]) latestProxyMemberRef.current = currentOption?.member ?? null const handleGroupChange = useCallback( (newGroupName: string) => { if (isGlobalMode || isDirectMode) return setSelectedGroupName(newGroupName) writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName) }, [isDirectMode, isGlobalMode, writeProfileScopedItem], ) const handleProxyChange = useCallback( (value: string) => { if (isDirectMode) return const option = unsortedProxyOptions.find( (candidate) => candidate.value === value, ) if (!selectedGroup || !option || !isInteractableMember(option.member)) { return } const previousProxy = selectedGroup.now const nextName = option.member.ref.name // The profile selection is the durable source across core restarts and run modes. handleSelectChange( selectedGroup.name, previousProxy!, selectedGroup.fixed!, )({ target: { value: nextName }, }) }, [handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions], ) const goToProxies = useCallback(() => { navigate('/proxies') }, [navigate]) const currentMember = currentOption?.member const currentProxy = currentMember ? memberDetails(currentMember) : undefined const selectedProxyName = currentMember?.ref.name ?? '' const currentDelay = currentMember && selectedGroupName ? delayManager.getDelayFix(currentMember, selectedGroupName) : -1 const signalInfo = currentProxy && selectedGroupName ? getSignalIcon(currentDelay, t) : { icon: , text: t('home.components.currentProxy.status.uninitialized'), color: 'text.secondary', } const checkCurrentProxyDelay = useCallback(async () => { if (autoCheckInProgressRef.current) return if (isDirectMode) return const groupName = selectedGroupName const proxyName = selectedProxyName if (!groupName || !proxyName) return const proxyMember = latestProxyMemberRef.current if (!proxyMember || !isInteractableMember(proxyMember)) { debugLog( `[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`, ) return } autoCheckInProgressRef.current = true const timeout = latestTimeoutRef.current || 10000 try { debugLog( `[CurrentProxyCard] 自动检测当前节点延迟,组: ${groupName}, 节点: ${proxyName}`, ) await delayManager.checkDelay(proxyMember, groupName, timeout) } catch (error) { console.error( `[CurrentProxyCard] 自动检测当前节点延迟失败,组: ${groupName}, 节点: ${proxyName}`, error, ) } finally { autoCheckInProgressRef.current = false refreshProxy() } }, [isDirectMode, refreshProxy, selectedGroupName, selectedProxyName]) useEffect(() => { if (isDirectMode) return if (!autoDelayEnabled) return if (!selectedGroupName || !selectedProxyName) return let disposed = false let intervalTimer: ReturnType | null = null let initialTimer: ReturnType | null = null const runAndSchedule = async () => { if (disposed) return await checkCurrentProxyDelay() if (disposed) return intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs) } initialTimer = setTimeout(async () => { await checkCurrentProxyDelay() if (disposed) return intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs) }, AUTO_CHECK_INITIAL_DELAY_MS) return () => { disposed = true if (initialTimer) clearTimeout(initialTimer) if (intervalTimer) clearTimeout(intervalTimer) } }, [ checkCurrentProxyDelay, autoDelayIntervalMs, isDirectMode, selectedGroupName, selectedProxyName, autoDelayEnabled, ]) const handleSortTypeChange = useCallback(() => { const newSortType = ((sortType + 1) % 3) as ProxySortType setSortType(newSortType) localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType.toString()) }, [sortType]) const handleCheckDelay = useLockFn(async () => { const groupName = selectedGroupName if (!groupName || isDirectMode) return debugLog(`[CurrentProxyCard] 开始测试所有延迟,组: ${groupName}`) const timeout = verge?.default_latency_timeout || 10000 const interactable = unsortedProxyOptions .map(({ member }) => member) .filter(isInteractableMember) .filter(({ ref }) => ref.name !== 'DIRECT' && ref.name !== 'REJECT') if (interactable.length > 0) { const url = delayManager.getUrl(groupName) debugLog(`[CurrentProxyCard] 测试URL: ${url}, 超时: ${timeout}ms`) try { await delayManager.checkListDelay(interactable, groupName, timeout) debugLog(`[CurrentProxyCard] 延迟测试完成,组: ${groupName}`) } catch (error) { console.error( `[CurrentProxyCard] 延迟测试出错,组: ${groupName}`, error, ) } } refreshProxy() mutate(() => syncTrayProxySelection(), { id: 'sync-tray-selection', errorNotice: false, }).catch((error) => { console.error('[CurrentProxyCard] Failed to sync tray latency:', error) }) }) const proxyOptions = useMemo( () => isDirectMode || openSelect !== 'proxy' ? [] : sortProxyOptions( unsortedProxyOptions, sortType, delays.of, defaultLatencyTimeout!, ), [ delays, isDirectMode, openSelect, unsortedProxyOptions, sortType, defaultLatencyTimeout, ], ) const getSortIcon = (): React.ReactElement => { switch (sortType) { case 1: return case 2: return default: return } } const getSortTooltip = (): string => { switch (sortType) { case 0: return t('proxies.page.tooltips.sortDefault') case 1: return t('proxies.page.tooltips.sortDelay') case 2: return t('proxies.page.tooltips.sortName') default: return '' } } return ( {currentProxy ? signalInfo.icon : } } iconColor={currentProxy ? 'primary' : undefined} action={ {getSortIcon()} } > {isCoreDataPending ? ( ) : currentProxy || (!isDirectMode && selectedGroup) ? ( {currentProxy?.name ?? t('home.components.currentProxy.labels.noActiveNode')} {currentProxy?.type} {isGlobalMode && ( )} {isDirectMode && ( )} {currentProxy?.udp && ( )} {currentProxy?.tfo && ( )} {currentProxy?.xudp && ( )} {currentProxy?.mptcp && ( )} {currentProxy?.smux && ( )} {currentProxy && !isDirectMode && ( )} {selectedGroupName}} open={openSelect === 'group'} disabled={isGlobalMode || isDirectMode} keepOpenRef={delayButtonRef} onOpenChange={(open) => setOpenSelect(open ? 'group' : null)} renderOptions={() => selectableGroups.map((group) => ( handleGroupChange(group.name)} > {group.name} )) } /> setOpenSelect(open ? 'proxy' : null)} onChange={handleProxyChange} /> ) : ( {t('home.components.currentProxy.labels.noActiveNode')} )} ) }