mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-22 02:22:08 +08:00
* refactor(ipc): generate bindings with Specta * fix(profile): project IPC inputs to consumed fields * fix(events): remove unused refresh payloads * fix(webdav): return only consumed backup metadata * fix(network): trim unused interface metadata * fix(sysinfo): return only displayed system fields * chore(knip): check generated runtime exports * fix(proxy): confirm runtime chain updates before UI changes * fix(clipboard): propagate proxy environment copy failures * refactor(dns): remove ignored runtime patch * refactor(core): trim unused upgrade response fields * refactor(ipc): avoid redundant response copies * refactor(proxy): clarify automatic proxy query naming * chore(tests): remove added IPC regression tests * refactor(ipc): enforce typed command boundaries * chore(contract): check generated bindings before push * refactor(ipc): remove unused proxy liveness payloads
378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
import { useEffect, useMemo, useRef } from 'react'
|
|
|
|
import { useRuntimeConfig } from '@/hooks/use-clash'
|
|
import { useGroupsDelays } from '@/hooks/use-group-delays'
|
|
import { useVerge } from '@/hooks/use-verge'
|
|
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
|
|
import delayManager, { type DelaySnapshot } from '@/services/delay'
|
|
import {
|
|
isInteractableMember,
|
|
resolveMember,
|
|
selectGlobalChainNodes,
|
|
selectRuleChainMembers,
|
|
type ProxyGroupView,
|
|
type ProxyViewV1,
|
|
type ResolvedProxyMember,
|
|
} from '@/types/proxy-view'
|
|
import { debugLog } from '@/utils/debug'
|
|
|
|
import { filterSort } from './use-filter-sort'
|
|
import {
|
|
DEFAULT_STATE,
|
|
useHeadStateNew,
|
|
type HeadState,
|
|
} from './use-head-state'
|
|
import { useWindowWidth } from './use-window-width'
|
|
|
|
export interface ResolvedMemberOccurrence {
|
|
memberIndex: number
|
|
member: ResolvedProxyMember
|
|
}
|
|
|
|
type ProxyGroup = ProxyGroupView
|
|
|
|
export interface IRenderItem {
|
|
type: 0 | 1 | 2 | 3 | 4
|
|
key: string
|
|
group: ProxyGroup
|
|
member?: ResolvedMemberOccurrence
|
|
memberCol?: ResolvedMemberOccurrence[]
|
|
col?: number
|
|
headState?: HeadState
|
|
icon?: string
|
|
testUrl?: string
|
|
}
|
|
|
|
/**
|
|
* Whether the list about to be drawn contains anything a user would call content.
|
|
*
|
|
* Derived from the list itself rather than predicted beside it. The prediction that used to
|
|
* live in the empty-state model asked a different question in chain mode — whether any
|
|
* Selector or URLTest group existed — which is unrelated to what the chain list is actually
|
|
* built from, so both a false "empty" and a false "not empty" were reachable.
|
|
*
|
|
* A group header alone counts only when the group is visible; every other row is content by
|
|
* definition, including the members of a group that is hidden but expanded.
|
|
*/
|
|
export const hasRenderableItems = (
|
|
renderList: readonly IRenderItem[],
|
|
): boolean => renderList.some((item) => item.type !== 0 || !item.group.hidden)
|
|
|
|
type GroupCache = {
|
|
now: string | undefined
|
|
members: ProxyGroupView['members']
|
|
headState: HeadState
|
|
col: number
|
|
latencyTimeout: number | undefined
|
|
/// This group's own delays. Compared by identity so that a test settling in one group
|
|
/// does not throw away every other group's sorted order.
|
|
delays: DelaySnapshot | undefined
|
|
items: IRenderItem[]
|
|
}
|
|
|
|
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
|
|
|
|
const resolveOccurrences = (view: ProxyViewV1, group: ProxyGroupView) =>
|
|
group.members.map((member, memberIndex) => ({
|
|
memberIndex,
|
|
member: resolveMember(view, member),
|
|
}))
|
|
|
|
const memberKey = (
|
|
group: ProxyGroupView,
|
|
occurrence: ResolvedMemberOccurrence,
|
|
) => {
|
|
const { memberIndex, member } = occurrence
|
|
const identity =
|
|
member.kind === 'node' ? member.node.recordId : member.ref.name
|
|
return `${group.name}:${memberIndex}:${identity}`
|
|
}
|
|
|
|
const calculateColumns = (width: number, configCol: number): number => {
|
|
if (configCol > 0 && configCol < 6) return configCol
|
|
if (width > 1920) return 5
|
|
if (width > 1450) return 4
|
|
if (width > 1024) return 3
|
|
if (width >= 600) return 2
|
|
return 1
|
|
}
|
|
|
|
const groupOccurrences = <T>(list: T[], size: number): T[][] =>
|
|
list.reduce<T[][]>((acc, item) => {
|
|
const lastGroup = acc[acc.length - 1]
|
|
if (!lastGroup || lastGroup.length >= size) acc.push([item])
|
|
else lastGroup.push(item)
|
|
return acc
|
|
}, [])
|
|
|
|
const CHAIN_DELAY_GROUP = 'chain-mode'
|
|
|
|
const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
|
|
name: CHAIN_DELAY_GROUP,
|
|
type: 'Selector',
|
|
udp: false,
|
|
xudp: false,
|
|
tfo: false,
|
|
mptcp: false,
|
|
smux: false,
|
|
history: [],
|
|
extra: {},
|
|
members,
|
|
})
|
|
|
|
export const useRenderList = (
|
|
mode: string,
|
|
isChainMode?: boolean,
|
|
selectedGroup?: string | null,
|
|
) => {
|
|
const { proxyView } = useProxiesData()
|
|
const { refreshProxy } = useAppRefreshers()
|
|
const { verge } = useVerge()
|
|
const { width } = useWindowWidth()
|
|
const [headStates, setHeadState] = useHeadStateNew()
|
|
const latencyTimeout = verge?.default_latency_timeout
|
|
const { data: runtimeConfig } = useRuntimeConfig(!!isChainMode)
|
|
const runtimeProxies = (
|
|
runtimeConfig as RuntimeConfigWithProxySequence | null
|
|
)?.proxies
|
|
|
|
const col = useMemo(
|
|
() => calculateColumns(width, verge?.proxy_layout_column || 6),
|
|
[width, verge?.proxy_layout_column],
|
|
)
|
|
|
|
const chainOccurrences = useMemo(() => {
|
|
if (!proxyView || !isChainMode) return []
|
|
if (mode === 'rule' && selectedGroup) {
|
|
return selectRuleChainMembers(proxyView, selectedGroup)
|
|
}
|
|
if (!runtimeConfig) return []
|
|
return selectGlobalChainNodes(proxyView, runtimeProxies).map(
|
|
(node, memberIndex) => ({
|
|
memberIndex,
|
|
member: {
|
|
kind: 'node' as const,
|
|
ref: {
|
|
kind: 'node' as const,
|
|
name: node.name,
|
|
recordId: node.recordId,
|
|
},
|
|
node,
|
|
},
|
|
}),
|
|
)
|
|
}, [
|
|
isChainMode,
|
|
mode,
|
|
proxyView,
|
|
runtimeConfig,
|
|
runtimeProxies,
|
|
selectedGroup,
|
|
])
|
|
|
|
const chainOccurrencesRef = useRef(chainOccurrences)
|
|
chainOccurrencesRef.current = chainOccurrences
|
|
const chainDelayGroup =
|
|
mode === 'rule' && selectedGroup ? selectedGroup : CHAIN_DELAY_GROUP
|
|
const chainDelayKey = chainOccurrences
|
|
.map(({ member }) => {
|
|
if (member.kind !== 'node') return `${member.kind}:${member.ref.name}`
|
|
const source = member.node.source
|
|
return source.kind === 'provider'
|
|
? `provider:${source.providerName}:${source.proxyName}`
|
|
: `core:${source.proxyName}`
|
|
})
|
|
.join('\u0000')
|
|
|
|
useEffect(() => {
|
|
if (!isChainMode || !chainDelayKey) return
|
|
const interactable = chainOccurrencesRef.current
|
|
.map(({ member }) => member)
|
|
.filter(isInteractableMember)
|
|
if (interactable.length === 0) return
|
|
|
|
const handle = setTimeout(() => {
|
|
const timeout = verge?.default_latency_timeout || 10000
|
|
debugLog(`[ChainMode] 开始计算 ${interactable.length} 个节点的延迟`)
|
|
void delayManager.checkListDelay(interactable, chainDelayGroup, timeout)
|
|
}, 100)
|
|
|
|
return () => {
|
|
clearTimeout(handle)
|
|
}
|
|
}, [
|
|
chainDelayGroup,
|
|
chainDelayKey,
|
|
isChainMode,
|
|
verge?.default_latency_timeout,
|
|
])
|
|
|
|
// Every group this list draws, so a test settling in any of them re-sorts that group.
|
|
const renderedGroupNames = useMemo(() => {
|
|
if (!proxyView) return []
|
|
if (isChainMode)
|
|
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
|
|
return mode === 'rule' || mode === 'script'
|
|
? proxyView.groups.map(({ name }) => name)
|
|
: proxyView.global
|
|
? [proxyView.global.name]
|
|
: []
|
|
}, [isChainMode, mode, proxyView, selectedGroup])
|
|
const groupDelays = useGroupsDelays(renderedGroupNames)
|
|
|
|
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
|
|
const prevListRef = useRef<IRenderItem[]>([])
|
|
|
|
const renderList = useMemo<IRenderItem[]>(() => {
|
|
if (!proxyView) return []
|
|
|
|
if (isChainMode) {
|
|
const selected =
|
|
mode === 'rule'
|
|
? proxyView.groups.find(({ name }) => name === selectedGroup)
|
|
: undefined
|
|
const group = selected ?? virtualGroup([])
|
|
const occurrences = filterSort(
|
|
chainOccurrences,
|
|
selected?.name ?? CHAIN_DELAY_GROUP,
|
|
'',
|
|
0,
|
|
latencyTimeout!,
|
|
)
|
|
if (col > 1) {
|
|
return groupOccurrences(occurrences, col).map((memberCol) => ({
|
|
type: 4,
|
|
key: `chain-col:${memberKey(group, memberCol[0])}`,
|
|
group,
|
|
headState: DEFAULT_STATE,
|
|
col,
|
|
memberCol,
|
|
}))
|
|
}
|
|
return occurrences.map((member) => ({
|
|
type: 2,
|
|
key: `chain:${memberKey(group, member)}`,
|
|
group,
|
|
member,
|
|
headState: DEFAULT_STATE,
|
|
}))
|
|
}
|
|
|
|
const useRule = mode === 'rule' || mode === 'script'
|
|
const renderGroups = useRule
|
|
? proxyView.groups
|
|
: proxyView.global === null
|
|
? []
|
|
: [proxyView.global]
|
|
const cache = groupCacheRef.current
|
|
let anyChanged = false
|
|
|
|
const retList = renderGroups.flatMap((group) => {
|
|
const headState = headStates[group.name] || DEFAULT_STATE
|
|
const cached = cache.get(group.name)
|
|
if (
|
|
cached &&
|
|
cached.now === group.now &&
|
|
cached.members === group.members &&
|
|
cached.headState === headState &&
|
|
cached.col === col &&
|
|
cached.latencyTimeout === latencyTimeout &&
|
|
cached.delays === groupDelays.get(group.name)
|
|
) {
|
|
return cached.items
|
|
}
|
|
|
|
anyChanged = true
|
|
const ret: IRenderItem[] = [
|
|
{
|
|
type: 0,
|
|
key: group.name,
|
|
group,
|
|
headState,
|
|
icon: group.icon!,
|
|
testUrl: group.testUrl!,
|
|
},
|
|
]
|
|
|
|
if (headState.open || !useRule) {
|
|
const occurrences = filterSort(
|
|
resolveOccurrences(proxyView, group),
|
|
group.name,
|
|
headState.filterText,
|
|
headState.sortType,
|
|
latencyTimeout!,
|
|
{
|
|
matchCase: headState.filterMatchCase,
|
|
matchWholeWord: headState.filterMatchWholeWord,
|
|
useRegularExpression: headState.filterUseRegularExpression,
|
|
},
|
|
)
|
|
if (!useRule) {
|
|
ret.push({ type: 1, key: `head-${group.name}`, group, headState })
|
|
}
|
|
if (occurrences.length === 0) {
|
|
ret.push({ type: 3, key: `empty-${group.name}`, group, headState })
|
|
} else if (col > 1) {
|
|
ret.push(
|
|
...groupOccurrences(occurrences, col).map((memberCol) => ({
|
|
type: 4 as const,
|
|
key: `col:${memberKey(group, memberCol[0])}`,
|
|
group,
|
|
headState,
|
|
col,
|
|
memberCol,
|
|
})),
|
|
)
|
|
} else {
|
|
ret.push(
|
|
...occurrences.map((member) => ({
|
|
type: 2 as const,
|
|
key: memberKey(group, member),
|
|
group,
|
|
member,
|
|
headState,
|
|
})),
|
|
)
|
|
}
|
|
}
|
|
|
|
cache.set(group.name, {
|
|
now: group.now!,
|
|
members: group.members,
|
|
headState,
|
|
col,
|
|
latencyTimeout: latencyTimeout!,
|
|
delays: groupDelays.get(group.name),
|
|
items: ret,
|
|
})
|
|
return ret
|
|
})
|
|
|
|
const filtered = !useRule
|
|
? retList.slice(1)
|
|
: retList.filter((item) => !item.group.hidden)
|
|
if (!anyChanged && prevListRef.current.length === filtered.length) {
|
|
return prevListRef.current
|
|
}
|
|
prevListRef.current = filtered
|
|
return filtered
|
|
}, [
|
|
chainOccurrences,
|
|
col,
|
|
groupDelays,
|
|
headStates,
|
|
isChainMode,
|
|
latencyTimeout,
|
|
mode,
|
|
proxyView,
|
|
selectedGroup,
|
|
])
|
|
|
|
return {
|
|
renderList,
|
|
onProxies: refreshProxy,
|
|
onHeadState: setHeadState,
|
|
currentColumns: col,
|
|
}
|
|
}
|