Files
clash-verge-rev/src/components/setting/mods/tun-viewer.tsx
T
Tunglies 5b8edbaead feat(tun): expose mihomo tun dns-mode in TUN settings
Support the dns-mode field introduced in mihomo v1.19.33. A
button group below the stack selector offers
Auto/Disabled/Native/Hijack; Auto writes an empty string, which
the core treats as the legacy behavior. Adding dns-mode to
GUI_KEYS makes the value GUI-authoritative once saved, matching
the other TUN dialog fields.
2026-10-25 16:04:59 +08:00

382 lines
12 KiB
TypeScript

import {
Box,
Button,
List,
ListItem,
ListItemText,
TextField,
Typography,
} from '@mui/material'
import { useLockFn } from 'ahooks'
import type { Ref } from 'react'
import { useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseDialog,
BaseSplitChipEditor,
TooltipIcon,
DialogRef,
Switch,
} from '@/components/base'
import { useClash } from '@/hooks/use-clash'
import { enhanceProfiles } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system'
import { areValidIpCidrs } from '@/utils/network'
import { DnsModeSwitch } from './dns-mode-switch'
import { StackModeSwitch } from './stack-mode-switch'
const OS = getSystem()
const splitRouteExcludeAddress = (value: string) =>
value
.split(/[,\n;\r]+/)
.map((item) => item.trim())
.filter(Boolean)
export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation()
const { clash, mutateClash, patchClash } = useClash()
const [open, setOpen] = useState(false)
const [values, setValues] = useState({
stack: 'mips',
device: OS === 'macos' ? 'utun1024' : 'Mihomo',
autoRoute: true,
routeExcludeAddress: '',
autoRedirect: false,
autoDetectInterface: true,
dnsHijack: ['any:53'],
strictRoute: false,
mtu: 1500,
dnsMode: '',
})
const routeExcludeAddressItems = splitRouteExcludeAddress(
values.routeExcludeAddress,
)
const routeExcludeAddressError =
values.autoRoute &&
routeExcludeAddressItems.length > 0 &&
!areValidIpCidrs(routeExcludeAddressItems)
const routeExcludeAddressHelperText = routeExcludeAddressError
? t('settings.modals.tun.messages.invalidRouteExcludeAddress')
: t('settings.modals.tun.messages.routeExcludeAddressHint')
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
const nextAutoRoute = clash?.tun['auto-route'] ?? true
const rawAutoRedirect = clash?.tun['auto-redirect'] ?? false
const computedAutoRedirect =
OS === 'linux' ? (nextAutoRoute ? rawAutoRedirect : false) : false
setValues({
stack: clash?.tun.stack ?? 'mips',
device: clash?.tun.device ?? (OS === 'macos' ? 'utun1024' : 'Mihomo'),
autoRoute: nextAutoRoute,
routeExcludeAddress: (clash?.tun['route-exclude-address'] ?? []).join(
',',
),
autoRedirect: computedAutoRedirect,
autoDetectInterface: clash?.tun['auto-detect-interface'] ?? true,
dnsHijack: clash?.tun['dns-hijack'] ?? ['any:53'],
strictRoute: clash?.tun['strict-route'] ?? false,
mtu: clash?.tun.mtu ?? 1500,
dnsMode: clash?.tun['dns-mode'] ?? '',
})
},
close: () => setOpen(false),
}))
const onSave = useLockFn(async () => {
try {
const routeExcludeAddress = routeExcludeAddressItems
if (routeExcludeAddressError) {
showNotice.error(
'settings.modals.tun.messages.invalidRouteExcludeAddress',
)
return
}
const tun: IConfigData['tun'] = {
stack: values.stack,
device:
values.device === ''
? OS === 'macos'
? 'utun1024'
: 'Mihomo'
: values.device,
'auto-route': values.autoRoute,
'route-exclude-address': routeExcludeAddress,
...(OS === 'linux'
? {
'auto-redirect': values.autoRedirect,
}
: {}),
'auto-detect-interface': values.autoDetectInterface,
'dns-hijack': values.dnsHijack[0] === '' ? [] : values.dnsHijack,
'dns-mode': values.dnsMode,
'strict-route': values.strictRoute,
mtu: values.mtu ?? 1500,
}
await patchClash({ tun })
await mutateClash(
(old) => ({
...old!,
tun,
}),
false,
)
setOpen(false)
showNotice.success('settings.modals.tun.messages.applied')
void mutate(() => enhanceProfiles(), {
id: 'enhance-profiles',
errorNotice: false,
}).catch((err: any) => {
showNotice.error(err)
})
} catch (err: any) {
showNotice.error(err)
}
})
return (
<BaseDialog
open={open}
title={
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="h6">{t('settings.modals.tun.title')}</Typography>
<Button
variant="outlined"
size="small"
onClick={async () => {
const tun: IConfigData['tun'] = {
stack: 'mips',
device: OS === 'macos' ? 'utun1024' : 'Mihomo',
'auto-route': true,
...(OS === 'linux'
? {
'auto-redirect': false,
}
: {}),
'auto-detect-interface': true,
'dns-hijack': ['any:53'],
'route-exclude-address': [],
'dns-mode': '',
'strict-route': false,
mtu: 1500,
}
setValues({
stack: 'mips',
device: OS === 'macos' ? 'utun1024' : 'Mihomo',
autoRoute: true,
routeExcludeAddress: '',
autoRedirect: false,
autoDetectInterface: true,
dnsHijack: ['any:53'],
strictRoute: false,
mtu: 1500,
dnsMode: '',
})
await patchClash({ tun })
await mutateClash(
(old) => ({
...old!,
tun,
}),
false,
)
}}
>
{t('shared.actions.resetToDefault')}
</Button>
</Box>
}
contentSx={{ width: 450 }}
okBtn={t('shared.actions.save')}
cancelBtn={t('shared.actions.cancel')}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText primary={t('settings.modals.tun.fields.stack')} />
<StackModeSwitch
value={values.stack}
onChange={(value) => {
setValues((v) => ({
...v,
stack: value,
}))
}}
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.tun.fields.dnsMode')}
sx={{ maxWidth: 'fit-content' }}
/>
<TooltipIcon
title={t('settings.modals.tun.tooltips.dnsMode')}
sx={{ opacity: 0.7 }}
/>
<Box sx={{ marginLeft: 'auto' }}>
<DnsModeSwitch
value={values.dnsMode}
onChange={(value) => {
setValues((v) => ({ ...v, dnsMode: value }))
}}
/>
</Box>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText primary={t('settings.modals.tun.fields.device')} />
<TextField
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.device}
placeholder="Mihomo"
onChange={(e) =>
setValues((v) => ({ ...v, device: e.target.value }))
}
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText primary={t('settings.modals.tun.fields.autoRoute')} />
<Switch
edge="end"
checked={values.autoRoute}
onChange={(_, c) =>
setValues((v) => ({
...v,
autoRoute: c,
autoRedirect: c ? v.autoRedirect : false,
}))
}
/>
</ListItem>
{OS === 'linux' && (
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.tun.fields.autoRedirect')}
sx={{ maxWidth: 'fit-content' }}
/>
<TooltipIcon
title={t('settings.modals.tun.tooltips.autoRedirect')}
sx={{ opacity: values.autoRoute ? 0.7 : 0.3 }}
/>
<Switch
edge="end"
checked={values.autoRedirect}
onChange={(_, c) =>
setValues((v) => ({
...v,
autoRedirect: v.autoRoute ? c : v.autoRedirect,
}))
}
disabled={!values.autoRoute}
sx={{ marginLeft: 'auto' }}
/>
</ListItem>
)}
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText primary={t('settings.modals.tun.fields.strictRoute')} />
<Switch
edge="end"
checked={values.strictRoute}
onChange={(_, c) => setValues((v) => ({ ...v, strictRoute: c }))}
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.tun.fields.autoDetectInterface')}
/>
<Switch
edge="end"
checked={values.autoDetectInterface}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoDetectInterface: c }))
}
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText primary={t('settings.modals.tun.fields.dnsHijack')} />
<TextField
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.dnsHijack.join(',')}
placeholder={t('settings.modals.tun.tooltips.dnsHijack')}
onChange={(e) =>
setValues((v) => ({ ...v, dnsHijack: e.target.value.split(',') }))
}
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText primary={t('settings.modals.tun.fields.mtu')} />
<TextField
autoComplete="new-password"
size="small"
type="number"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.mtu}
placeholder="1500"
onChange={(e) =>
setValues((v) => ({
...v,
mtu: parseInt(e.target.value),
}))
}
/>
</ListItem>
<BaseSplitChipEditor
value={values.routeExcludeAddress}
placeholder="192.168.0.0/16"
ariaLabel={t('settings.modals.tun.fields.routeExcludeAddress')}
disabled={!values.autoRoute}
error={routeExcludeAddressError}
helperText={routeExcludeAddressHelperText}
onChange={(nextValue) =>
setValues((v) => ({ ...v, routeExcludeAddress: nextValue }))
}
renderHeader={(modeToggle) => (
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.tun.fields.routeExcludeAddress')}
/>
{modeToggle ? (
<Box sx={{ marginLeft: 'auto' }}>{modeToggle}</Box>
) : null}
</ListItem>
)}
/>
</List>
</BaseDialog>
)
}