Files
clash-verge-rev/src/components/proxy/proxy-chain.tsx
T
Tunglies 6e08a77a84 refactor(backend): specta ipc bindings (#8195)
* 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
2026-10-12 06:16:05 +00:00

734 lines
19 KiB
TypeScript

import { arrayMove } from '@dnd-kit/helpers'
import {
DragDropProvider,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
import {
ArrowDownward,
Delete as DeleteIcon,
DragIndicator,
Link,
LinkOff,
WarningRounded,
} from '@mui/icons-material'
import {
Alert,
Box,
Button,
Chip,
IconButton,
Paper,
Typography,
keyframes,
useTheme,
} from '@mui/material'
import * as yaml from 'js-yaml'
import {
type Ref,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
closeAllConnections,
selectNodeForGroup,
} from 'tauri-plugin-mihomo-api'
import { TooltipIcon } from '@/components/base'
import { useRuntimeConfig } from '@/hooks/use-clash'
import { useRecordSelection } from '@/hooks/use-record-selection'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import {
selectGlobalChainNodes,
selectRuleChainMembers,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { rebindProxyChainItems, type ProxyChainItem } from './proxy-chain-model'
const chainPointerSensor = PointerSensor.configure({
activationConstraints: () => undefined,
})
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
interface ParsedChainConfig {
proxies?: Array<{
name: string
type: string
[key: string]: any
}>
}
interface ProxyChainProps {
proxyChain: ProxyChainItem[]
onUpdateChain: (chain: ProxyChainItem[]) => void
chainConfigData?: string | null
onMarkUnsavedChanges?: () => void
mode?: string
selectedGroup?: string | null
}
interface ProxyChainItemProps {
id: string
proxy: ProxyChainItem
index: number
isFirst: boolean
isLast: boolean
onRemove: (id: string) => void
}
const arrowFadeIn = keyframes`
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
`
const toChainItems = (
parsedConfig: ParsedChainConfig | null | undefined,
): ProxyChainItem[] => {
const timestamp = Date.now()
return (
parsedConfig?.proxies?.map((proxy, index) => ({
id: `${proxy.name}_${timestamp}_${index}`,
name: proxy.name,
type: proxy.type,
delay: undefined,
})) || []
)
}
interface ChainCardProps {
proxy: ProxyChainItem
index: number
isFirst: boolean
isLast: boolean
isDragging?: boolean
isDropping?: boolean
handleRef?: Ref<HTMLElement> | null
onRemove?: (id: string) => void
}
const ChainCard = ({
proxy,
index,
isFirst,
isLast,
isDragging,
isDropping,
handleRef,
onRemove,
}: ChainCardProps) => {
const theme = useTheme()
const { t } = useTranslation()
const roleLabel = isFirst
? t('proxies.page.chain.entryNode')
: isLast
? t('proxies.page.chain.exitNode')
: undefined
const roleColor = isFirst
? theme.palette.success.main
: isLast
? theme.palette.warning.main
: undefined
return (
<Box
sx={{
mb: 0,
display: 'flex',
alignItems: 'center',
p: 1,
backgroundColor: theme.palette.background.default,
borderRadius: 1,
border: roleColor
? `1.5px solid ${roleColor}`
: `1px solid ${theme.palette.divider}`,
opacity: proxy.recordId === undefined ? 0.55 : undefined,
transition: 'box-shadow 0.2s, background-color 0.2s',
boxShadow: isDropping
? `0 0 0 2px ${theme.palette.primary.main}66`
: undefined,
}}
>
<Box
ref={handleRef}
sx={{
display: 'flex',
alignItems: 'center',
mr: 1,
color: theme.palette.text.secondary,
cursor: isDragging ? 'grabbing' : 'grab',
}}
>
<DragIndicator />
</Box>
{roleLabel ? (
<Chip
label={roleLabel}
size="small"
sx={{
mr: 1,
fontWeight: 700,
color: '#fff',
backgroundColor: roleColor,
}}
/>
) : (
<Chip
label={`${index + 1}`}
size="small"
color="primary"
sx={{ mr: 1, minWidth: 32 }}
/>
)}
<Typography
variant="body2"
sx={{
flex: 1,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{proxy.name}
</Typography>
{proxy.type && (
<Chip
label={proxy.type}
size="small"
variant="outlined"
sx={{ mr: 1 }}
/>
)}
{proxy.delay !== undefined && (
<Chip
label={
proxy.delay > 0 ? `${proxy.delay}ms` : t('shared.labels.timeout')
}
size="small"
color={
proxy.delay > 0 && proxy.delay < 200
? 'success'
: proxy.delay > 0 && proxy.delay < 800
? 'warning'
: 'error'
}
sx={{ mr: 1, fontSize: '0.7rem', minWidth: 50 }}
/>
)}
{onRemove && (
<IconButton
size="small"
onClick={() => onRemove(proxy.id)}
sx={{
color: theme.palette.error.main,
'&:hover': {
backgroundColor: theme.palette.error.light + '20',
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
)
}
const SortableProxyChainItem = ({
id,
proxy,
index,
isFirst,
isLast,
onRemove,
}: ProxyChainItemProps) => {
const theme = useTheme()
const [element, setElement] = useState<Element | null>(null)
const handleRef = useRef<HTMLElement | null>(null)
const { isDragging, isDropping } = useSortable({
id,
index,
element,
handle: handleRef,
})
return (
<Box
ref={setElement}
sx={{
width: '100%',
'&[data-dnd-dragging]': {
height: 'auto',
boxShadow: 'none !important',
'.proxy-chain-arrow': {
display: 'none',
},
},
'& .proxy-chain-arrow': {
animation: `${arrowFadeIn} 0.25s ease`,
},
}}
>
<ChainCard
proxy={proxy}
index={index}
isFirst={isFirst}
isLast={isLast}
isDragging={isDragging}
isDropping={isDropping}
handleRef={handleRef}
onRemove={onRemove}
/>
{!isLast && (
<Box
className="proxy-chain-arrow"
sx={{
display: 'flex',
justifyContent: 'center',
py: 0.25,
}}
>
<ArrowDownward
sx={{
fontSize: 20,
color: theme.palette.primary.main,
opacity: 0.7,
}}
/>
</Box>
)}
</Box>
)
}
export const ProxyChain = ({
proxyChain,
onUpdateChain,
chainConfigData,
onMarkUnsavedChanges,
mode,
selectedGroup,
}: ProxyChainProps) => {
const theme = useTheme()
const { t } = useTranslation()
const chainWarning = t('proxies.page.chain.warning')
const { proxyView } = useProxiesData()
const { refreshProxy } = useAppRefreshers()
const { data: runtimeConfig } = useRuntimeConfig(true)
const [isConnecting, setIsConnecting] = useState(false)
const recordSelection = useRecordSelection()
const markUnsavedChanges = useCallback(() => {
onMarkUnsavedChanges?.()
}, [onMarkUnsavedChanges])
const candidates = useMemo(() => {
if (!proxyView) return []
if (mode === 'rule' && selectedGroup) {
return selectRuleChainMembers(proxyView, selectedGroup).flatMap(
({ member }) => (member.kind === 'node' ? [member.node] : []),
)
}
if (!runtimeConfig) return []
const runtimeProxies = (
runtimeConfig as RuntimeConfigWithProxySequence | null
)?.proxies
return selectGlobalChainNodes(proxyView, runtimeProxies)
}, [mode, proxyView, runtimeConfig, selectedGroup])
const currentProxyChain = useMemo(
() =>
proxyView
? rebindProxyChainItems(proxyChain, candidates, proxyView)
: proxyChain.map((item) => ({
...item,
recordId: undefined,
delay: undefined,
})),
[candidates, proxyChain, proxyView],
)
const isConnected = useMemo(() => {
if (!proxyView || currentProxyChain.length === 0) {
return false
}
const lastNode = currentProxyChain[currentProxyChain.length - 1]
if (localStorage.getItem('proxy-chain-exit-node') === lastNode.name) {
return true
}
if (currentProxyChain.length < 2) return false
if (mode === 'global') {
return proxyView.global?.now === lastNode.name
}
if (!selectedGroup) {
return false
}
const proxyChainGroup = proxyView.groups.find(
(group) => group.name === selectedGroup,
)
return proxyChainGroup?.now === lastNode.name
}, [proxyView, currentProxyChain, mode, selectedGroup])
// 监听链的变化,但排除从配置加载的情况
const chainLengthRef = useRef(currentProxyChain.length)
useEffect(() => {
// 只有当链长度发生变化且不是初始加载时,才标记为未保存
if (
chainLengthRef.current !== currentProxyChain.length &&
chainLengthRef.current !== 0
) {
markUnsavedChanges()
}
chainLengthRef.current = currentProxyChain.length
}, [currentProxyChain.length, markUnsavedChanges])
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: newIndex, initialIndex: oldIndex } = source.sortable
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= currentProxyChain.length ||
newIndex >= currentProxyChain.length ||
oldIndex === newIndex
) {
return
}
onUpdateChain(arrayMove(currentProxyChain, oldIndex, newIndex))
markUnsavedChanges()
},
[currentProxyChain, onUpdateChain, markUnsavedChanges],
)
const handleRemoveProxy = useCallback(
(id: string) => {
const newChain = currentProxyChain.filter((item) => item.id !== id)
onUpdateChain(newChain)
markUnsavedChanges()
},
[currentProxyChain, onUpdateChain, markUnsavedChanges],
)
const handleConnect = useCallback(async () => {
if (isConnected) {
setIsConnecting(true)
try {
const result = await mutate(
() => updateProxyChainConfigInRuntime(null),
{
id: 'update-proxy-chain-runtime',
errorNotice: false,
},
)
if (!result.ok) return
const targetGroup =
mode === 'global'
? 'GLOBAL'
: selectedGroup || localStorage.getItem('proxy-chain-group')
if (targetGroup) {
try {
await mutate(() => selectNodeForGroup(targetGroup, 'DIRECT'), {
id: `select-proxy-node:${targetGroup}`,
errorNotice: false,
})
await recordSelection(targetGroup, 'DIRECT')
} catch {
if (currentProxyChain.length >= 1) {
try {
await mutate(
() =>
selectNodeForGroup(targetGroup, currentProxyChain[0].name),
{
id: `select-proxy-node:${targetGroup}`,
errorNotice: false,
},
)
await recordSelection(targetGroup, currentProxyChain[0].name)
} catch {
// ignore
}
}
}
}
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
await mutate(() => closeAllConnections(), {
id: 'close-all-connections',
errorNotice: false,
})
await refreshProxy()
onUpdateChain([])
} catch (error) {
console.error('Failed to disconnect from proxy chain:', error)
alert(t('proxies.page.chain.disconnectFailed'))
} finally {
setIsConnecting(false)
}
return
}
if (mode === 'global' && proxyView?.global === null) {
alert(t('proxies.page.chain.connectFailed'))
return
}
if (
currentProxyChain.length < 2 ||
currentProxyChain.some(({ recordId }) => !recordId)
) {
alert(t('proxies.page.chain.minimumNodes'))
return
}
setIsConnecting(true)
try {
// 第一步:保存链式代理配置
const chainProxies = currentProxyChain.map((node) => node.name)
debugLog('Saving chain config:', chainProxies)
const result = await mutate(
() => updateProxyChainConfigInRuntime(chainProxies),
{
id: 'update-proxy-chain-runtime',
errorNotice: false,
},
)
if (!result.ok) return
debugLog('Chain configuration saved successfully')
// 第二步:连接到代理链的最后一个节点
const lastNode = currentProxyChain[currentProxyChain.length - 1]
debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`)
// 根据模式确定使用的代理组名称
if (mode !== 'global' && !selectedGroup) {
throw new Error('规则模式下必须选择代理组')
}
const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup
await mutate(
() => selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name),
{
id: `select-proxy-node:${targetGroup || 'GLOBAL'}`,
errorNotice: false,
},
)
// The chain moves the group like any other selection, so the profile has to learn about
// it: what the profile holds is what gets re-applied the next time the core starts.
await recordSelection(targetGroup || 'GLOBAL', lastNode.name)
localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL')
localStorage.setItem('proxy-chain-exit-node', lastNode.name)
// 刷新代理信息以更新连接状态
await refreshProxy()
debugLog('Successfully connected to proxy chain')
} catch (error) {
console.error('Failed to connect to proxy chain:', error)
alert(t('proxies.page.chain.connectFailed'))
} finally {
setIsConnecting(false)
}
}, [
currentProxyChain,
isConnected,
t,
refreshProxy,
mode,
proxyView,
selectedGroup,
onUpdateChain,
recordSelection,
])
// 处理链式代理配置数据
useEffect(() => {
if (chainConfigData) {
try {
// JSON is valid YAML, so one parser covers both persisted formats.
const parsedConfig = yaml.load(chainConfigData) as ParsedChainConfig
const chainItems = toChainItems(parsedConfig)
if (chainItems.length > 0) {
onUpdateChain(chainItems)
}
} catch (error) {
console.error('Failed to process chain config data:', error)
}
}
}, [chainConfigData, onUpdateChain])
return (
<Paper
elevation={1}
sx={{
height: '100%',
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Typography variant="h6">{t('proxies.page.chain.header')}</Typography>
<TooltipIcon
title={chainWarning}
icon={WarningRounded}
color="warning"
sx={{ p: 0.25 }}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{currentProxyChain.length > 0 && (
<IconButton
size="small"
onClick={() => {
void mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
onFulfilled: () => {
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
onUpdateChain([])
},
}).catch(() => {})
}}
sx={{
color: theme.palette.error.main,
'&:hover': {
backgroundColor: theme.palette.error.light + '20',
},
}}
title={t('proxies.page.actions.clearChainConfig')}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<Button
size="small"
variant="contained"
startIcon={isConnected ? <LinkOff /> : <Link />}
onClick={handleConnect}
disabled={
isConnecting ||
(!isConnected &&
(currentProxyChain.length < 2 ||
currentProxyChain.some(
({ recordId }) => recordId === undefined,
) ||
(mode === 'global' && proxyView?.global === null) ||
(mode !== 'global' && !selectedGroup)))
}
color={isConnected ? 'error' : 'success'}
sx={{
minWidth: 90,
}}
title={
!isConnected && currentProxyChain.length < 2
? t('proxies.page.chain.minimumNodes')
: undefined
}
>
{isConnecting
? t('proxies.page.actions.connecting')
: isConnected
? t('proxies.page.actions.disconnect')
: t('proxies.page.actions.connect')}
</Button>
</Box>
</Box>
<Alert
severity={currentProxyChain.length === 1 ? 'warning' : 'info'}
sx={{ mb: 2 }}
>
{currentProxyChain.length === 1
? t('proxies.page.chain.minimumNodesHint')
: t('proxies.page.chain.instruction')}
</Alert>
<Box sx={{ flex: 1, overflow: 'auto' }}>
{currentProxyChain.length === 0 ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme.palette.text.secondary,
}}
>
<Typography>{t('proxies.page.chain.empty')}</Typography>
</Box>
) : (
<DragDropProvider
sensors={[chainPointerSensor, KeyboardSensor]}
onDragEnd={handleDragEnd}
>
<Box sx={{ borderRadius: 1, minHeight: 60, p: 1 }}>
{currentProxyChain.map((proxy, index) => (
<SortableProxyChainItem
key={proxy.id}
id={proxy.id}
proxy={proxy}
index={index}
isFirst={index === 0}
isLast={
index === currentProxyChain.length - 1 &&
currentProxyChain.length > 1
}
onRemove={handleRemoveProxy}
/>
))}
</Box>
</DragDropProvider>
)}
</Box>
</Paper>
)
}