Files
clash-verge-rev/src/components/setting/mods/sysproxy-viewer.tsx
T
Tunglies 69b76cc6a9 refactor(frontend): contract flow (#8131)
* feat(contract): export wire contract from backend and generate frontend types

Adds a zero-dependency `frontend-contract` bin that serializes the real
NoticeStatus variants and FrontendEvent payloads, scripts/gen-contract.mjs
which emits the committed src/services/contract.ts, and a CI job that
regenerates it and fails on a non-empty diff. events.ts now derives its
event map from the generated contract instead of hand-typed shapes.

* feat(events): route all backend events through a single event bus and store

Adds src/services/bus (the only listen/emit site: one handler per contract
event plus the frontend-internal test-all event) and the React
context/reducer app store. Migrates the five scattered subscriptions
(layout events, pending failures, proxy refresh, profile update/timer,
test-all), deletes the old glue (events.ts, use-listen, use-layout-events,
LoadingCacheProvider), moves run state into the store, and adds an
architecture gate script wired into CI. Behavior is unchanged; the
run-state query's poll/focus safety net is preserved by the store
provider.

* refactor(notice): make the notice table exhaustive and drop dead wire entries

D5 cleanup, frontend and backend in one commit: removes the six
config_validate::* statuses that never receive a backend send and the
set_config::ok send (enum variants, golden list 41→34, send point in
restart_clash_core), their frontend handlers, and the five i18n keys only
those handlers used. The notice table is now typed as a Record over the
generated NoticeStatus union, so a status without a frontend entry fails
to compile. The info status (sent by the silent updater for breaking
change notices, previously dropped with a console warning) now shows an
info toast — the one deliberate behavior delta.

* feat(mutate): single funnel for user-intent commands and invoke gate

Adds src/services/mutate.ts: one path for optimistic apply, services-layer
invoke, Busy handling, failure rollback, and the error toast, with busy
bookkeeping mirrored into the app store. The patch hooks (verge, clash,
profiles) now route through it. Moves the remaining direct invokes
(unlock ×3, DNS ×4) into cmds.ts and turns on the invoke-only-in-services
architecture gate in CI.

* refactor(settings): route settings-domain commands through the mutate funnel

Ports port saving, webdav/local backup operations, core change/restart/
upgrade, service install/uninstall/repair flows, the system-proxy toggle
queue, the DNS override toggle, and app lifecycle commands onto the single
funnel. Bespoke flows keep their own error handling via errorNotice:false;
simple flows gain the funnel's default error toast and revalidation.

* refactor(profiles): route profile operations through the mutate funnel

Ports profile create/import/reorder/delete/update/patch/save-file and
enhance calls onto the funnel; retry and editor flows keep their own
error handling with errorNotice:false. Per-uid ids keep the update
worker pool concurrent under the funnel's in-flight dedupe.

* refactor(proxies): route proxy/home/misc commands through the mutate funnel

Ports mode switching (page and home card), chain runtime updates, node
selection with tray sync, selection recording, provider updates and
syncs, and lightweight-mode entry onto the funnel; every site keeps its
existing optimistic/revert and error-handling behavior.

* docs: add frontend architecture guardrails for src

Layering rules for the contract flow: generated contract as the only
wire-typing source, event bus as the only listen/emit site, pure-reducer
store for event-derived state, the mutate funnel for user intent, and
invoke only in the services layer — each backed by the CI gates.

* fix(events): close run-state race and restore single error toasts

Self-review findings: the bus now re-reads the run-state snapshot in
onSubscribed so an event racing the store's initial read cannot leave a
stale snapshot (the ordering the old layout listener guaranteed), and the
patch hooks pass errorNotice:false so GuardState/page handlers remain the
only error toast, matching the pre-migration behavior.

* fix(frontend): preserve queued intents and fresh runtime snapshots

* refactor(frontend): trim unused mutation and busy state APIs

* docs(frontend): focus guardrails on architecture ownership
2026-10-07 07:38:55 +00:00

614 lines
19 KiB
TypeScript

import { EditRounded } from '@mui/icons-material'
import {
Autocomplete,
Box,
Button,
Chip,
InputAdornment,
List,
ListItem,
ListItemText,
styled,
TextField,
Typography,
} from '@mui/material'
import { useLockFn } from 'ahooks'
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseDialog,
BaseFieldset,
BaseSplitChipEditor,
type DialogRef,
Switch,
TooltipIcon,
} from '@/components/base'
import { EditorViewer } from '@/components/profile/editor-viewer'
import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
import { useSystemProxyState } from '@/hooks/use-system-proxy-state'
import { useVerge } from '@/hooks/use-verge'
import { useSystemData } from '@/providers/app-data-context'
import {
getEmbeddedServerPort,
getNetworkInterfacesInfo,
getSystemHostname,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
import getSystem from '@/utils/get-system'
const DEFAULT_PAC = `function FindProxyForURL(url, host) {
return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;";
}`
/** NO_PROXY validation */
// *., cdn*., *, etc.
const domain_subdomain_part = String.raw`(?:[a-z0-9\-\*]+\.|\*)*`
// .*, .cn, .moe, .co*, *
const domain_tld_part = String.raw`(?:\w{2,64}\*?|\*)`
// *epicgames*, *skk.moe, *.skk.moe, skk.*, sponsor.cdn.skk.moe, *.*, etc.
// also matches 192.168.*, 10.*, 127.0.0.*, etc. (partial ipv4)
const rDomainSimple = domain_subdomain_part + domain_tld_part
const ipv4_part = String.raw`\d{1,3}`
const ipv6_part = '(?:[a-fA-F0-9:])+'
const rLocal = `localhost|<local>|localdomain`
const getValidReg = (isWindows: boolean) => {
// 127.0.0.1 (full ipv4)
const rIPv4Unix = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}(?:\/\d{1,2})?`
const rIPv4Windows = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}`
const rIPv6Unix = String.raw`(?:${ipv6_part}:+)+${ipv6_part}(?:\/\d{1,3})?`
const rIPv6Windows = String.raw`(?:${ipv6_part}:+)+${ipv6_part}`
const rValidPart = `${rDomainSimple}|${
isWindows ? rIPv4Windows : rIPv4Unix
}|${isWindows ? rIPv6Windows : rIPv6Unix}|${rLocal}`
const separator = isWindows ? ';' : ','
const rValid = String.raw`^(${rValidPart})(?:${separator}\s?(${rValidPart}))*${separator}?$`
return new RegExp(rValid)
}
const splitBypass = (value?: string) =>
(value ?? '')
.split(/[,\n;\r]+/)
.map((item) => item.trim())
.filter(Boolean)
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation()
const systemName = getSystem()
const isWindows = systemName === 'windows'
const validReg = useMemo(() => getValidReg(isWindows), [isWindows])
const [open, setOpen] = useState(false)
const [editorOpen, setEditorOpen] = useState(false)
const [pacEditorValue, setPacEditorValue] = useState(DEFAULT_PAC)
const [pacEditorSavedValue, setPacEditorSavedValue] = useState(DEFAULT_PAC)
const [saving, setSaving] = useState(false)
const { verge, patchVerge, mutateVerge } = useVerge()
const [hostOptions, setHostOptions] = useState<string[]>([])
const { indicator: isProxyReallyEnabled, invalidateProxyState } =
useSystemProxyState()
const {
enable_system_proxy: enabled,
proxy_auto_config,
pac_file_content,
enable_proxy_guard,
enable_bypass_check,
use_default_bypass,
system_proxy_bypass,
proxy_guard_duration,
proxy_host,
} = verge ?? {}
const [value, setValue] = useState({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
null,
)
useEffect(() => {
getEmbeddedServerPort().then(setEmbeddedServerPort).catch(console.error)
}, [])
const separator = useMemo(() => (isWindows ? ';' : ','), [isWindows])
const defaultBypass = () => {
if (isWindows) {
return 'localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>'
}
if (systemName === 'linux') {
return 'localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1'
}
return '127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>'
}
const { systemProxyAddress } = useSystemData()
const displayedMixedPort = useDisplayedMixedPort()
const getSystemProxyAddress = useMemo(() => {
const isPacMode = value.pac ?? false
if (isPacMode) {
const host = value.proxy_host || '127.0.0.1'
return `${host}:${displayedMixedPort}`
} else {
return systemProxyAddress
}
}, [value.pac, value.proxy_host, displayedMixedPort, systemProxyAddress])
const getCurrentPacUrl = useMemo(() => {
const host = value.proxy_host || '127.0.0.1'
return embeddedServerPort
? `http://${host}:${embeddedServerPort}/commands/pac`
: '-'
}, [embeddedServerPort, value.proxy_host])
const bypassError =
value.enable_bypass_check &&
!value.pac &&
!value.use_default &&
value.bypass
? !validReg.test(value.bypass)
: false
const openPacEditor = () => {
const nextPac = value.pac_content ?? DEFAULT_PAC
setPacEditorValue(nextPac)
setPacEditorSavedValue(nextPac)
setEditorOpen(true)
}
const handleSavePac = useLockFn(async () => {
const nextPac =
pacEditorValue.trim().length > 0 ? pacEditorValue : DEFAULT_PAC
setValue((current) => ({ ...current, pac_content: nextPac }))
setPacEditorSavedValue(nextPac)
})
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
setValue({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
fetchNetworkInterfaces()
},
close: () => setOpen(false),
}))
const fetchNetworkInterfaces = async () => {
try {
const interfaces = await getNetworkInterfacesInfo()
const ipAddresses: string[] = []
interfaces.forEach((iface) => {
iface.addr.forEach((address) => {
if (address.V4?.ip) {
ipAddresses.push(address.V4.ip)
}
if (address.V6?.ip) {
ipAddresses.push(address.V6.ip)
}
})
})
let hostname = ''
try {
hostname = await getSystemHostname()
debugLog('获取到主机名:', hostname)
} catch (err) {
console.error('获取主机名失败:', err)
}
const options = ['127.0.0.1', 'localhost']
if (hostname) {
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
hostname = `${hostname}.local`
options.push(hostname)
debugLog('主机名已添加到选项中:', hostname)
} else {
debugLog('主机名与已有选项重复:', hostname)
}
} else {
debugLog('主机名为空')
}
options.push(...ipAddresses)
const uniqueOptions = Array.from(new Set(options))
debugLog('最终选项列表:', uniqueOptions)
setHostOptions(uniqueOptions)
} catch (error) {
console.error('获取网络接口失败:', error)
setHostOptions(['127.0.0.1', 'localhost'])
}
}
const onSave = useLockFn(async () => {
if (value.duration < 1) {
showNotice.error('settings.modals.sysproxy.messages.durationTooShort')
return
}
if (
value.enable_bypass_check &&
!value.pac &&
!value.use_default &&
value.bypass &&
!validReg.test(value.bypass)
) {
showNotice.error('settings.modals.sysproxy.messages.invalidBypass')
return
}
const ipv4Regex =
/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
const ipv6Regex =
/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/
const hostnameRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
if (
!ipv4Regex.test(value.proxy_host) &&
!ipv6Regex.test(value.proxy_host) &&
!hostnameRegex.test(value.proxy_host)
) {
showNotice.error('settings.modals.sysproxy.messages.invalidProxyHost')
return
}
setSaving(true)
setOpen(false)
setSaving(false)
const patch: Partial<IVergeConfig> = {}
if (value.guard !== enable_proxy_guard) {
patch.enable_proxy_guard = value.guard
}
if (value.enable_bypass_check !== enable_bypass_check) {
patch.enable_bypass_check = value.enable_bypass_check
}
if (value.duration !== proxy_guard_duration) {
patch.proxy_guard_duration = value.duration
}
if (value.bypass !== system_proxy_bypass) {
patch.system_proxy_bypass = value.bypass
}
if (value.pac !== proxy_auto_config) {
patch.proxy_auto_config = value.pac
}
if (value.use_default !== use_default_bypass) {
patch.use_default_bypass = value.use_default
}
let pacContent = value.pac_content
if (pacContent) {
pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host)
const mixedPortStr = displayedMixedPort.toString()
pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr)
}
if (pacContent !== pac_file_content) {
patch.pac_file_content = pacContent
}
let proxyHost = value.proxy_host
if (
ipv6Regex.test(proxyHost) &&
!proxyHost.startsWith('[') &&
!proxyHost.endsWith(']')
) {
proxyHost = `[${proxyHost}]`
}
if (proxyHost !== proxy_host) {
patch.proxy_host = proxyHost
}
Promise.resolve().then(async () => {
try {
if (Object.keys(patch).length > 0) {
mutateVerge({ ...verge, ...patch }, false)
}
if (Object.keys(patch).length > 0) {
await patchVerge(patch)
}
try {
await invalidateProxyState()
} catch (err) {
console.warn('代理状态更新失败:', err)
}
} catch (err) {
console.error('配置保存失败:', err)
mutateVerge()
showNotice.error(err)
}
})
})
return (
<BaseDialog
open={open}
title={t('settings.modals.sysproxy.title')}
contentSx={{ width: 450, maxHeight: 565 }}
okBtn={t('shared.actions.save')}
cancelBtn={t('shared.actions.cancel')}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
loading={saving}
disableOk={saving}
>
<List>
<BaseFieldset
label={t('settings.modals.sysproxy.fieldsets.currentStatus')}
padding="15px 10px"
>
<FlexBox>
<Typography className="label">
{t('settings.modals.sysproxy.fields.enableStatus')}
</Typography>
<Typography className="value">
{isProxyReallyEnabled
? t('shared.statuses.enabled')
: t('shared.statuses.disabled')}
</Typography>
</FlexBox>
{!value.pac && (
<FlexBox>
<Typography className="label">
{t('settings.modals.sysproxy.fields.serverAddr')}
</Typography>
<Typography className="value">{getSystemProxyAddress}</Typography>
</FlexBox>
)}
{value.pac && (
<FlexBox>
<Typography className="label">
{t('settings.modals.sysproxy.fields.pacUrl')}
</Typography>
<Typography className="value">
{getCurrentPacUrl || '-'}
</Typography>
</FlexBox>
)}
</BaseFieldset>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.sysproxy.fields.proxyHost')}
/>
<Autocomplete
size="small"
sx={{ width: 150 }}
options={hostOptions}
value={value.proxy_host}
freeSolo
renderInput={(params) => (
<TextField {...params} placeholder="127.0.0.1" size="small" />
)}
onChange={(_, newValue) => {
setValue((v) => ({
...v,
proxy_host: newValue || '127.0.0.1',
}))
}}
onInputChange={(_, newInputValue) => {
setValue((v) => ({
...v,
proxy_host: newInputValue || '127.0.0.1',
}))
}}
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.sysproxy.fields.usePacMode')}
/>
<Switch
edge="end"
disabled={!enabled}
checked={value.pac}
onChange={(_, e) => setValue((v) => ({ ...v, pac: e }))}
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.sysproxy.fields.proxyGuard')}
sx={{ maxWidth: 'fit-content' }}
/>
<TooltipIcon
title={t('settings.modals.sysproxy.tooltips.proxyGuard')}
sx={{ opacity: '0.7' }}
/>
<Switch
edge="end"
disabled={!enabled}
checked={value.guard}
onChange={(_, e) => setValue((v) => ({ ...v, guard: e }))}
sx={{ marginLeft: 'auto' }}
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.sysproxy.fields.guardDuration')}
/>
<TextField
disabled={!enabled}
size="small"
value={value.duration}
sx={{ width: 100 }}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">s</InputAdornment>,
},
}}
onChange={(e) => {
setValue((v) => ({
...v,
duration: +e.target.value.replace(/\D/, ''),
}))
}}
/>
</ListItem>
{!value.pac && (
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t(
'settings.modals.sysproxy.fields.alwaysUseDefaultBypass',
)}
/>
<Switch
edge="end"
disabled={!enabled}
checked={value.use_default}
onChange={(_, e) => {
if (!e && !value.bypass) {
const nextBypass = defaultBypass()
setValue((v) => ({
...v,
use_default: e,
bypass: nextBypass,
}))
return
}
setValue((v) => ({ ...v, use_default: e }))
}}
/>
</ListItem>
)}
{!value.pac && (
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.sysproxy.fields.enableBypassCheck')}
/>
<Switch
edge="end"
disabled={!enabled}
checked={value.enable_bypass_check}
onChange={(_, e) =>
setValue((v) => ({ ...v, enable_bypass_check: e }))
}
/>
</ListItem>
)}
{!value.pac && !value.use_default && (
<BaseSplitChipEditor
value={value.bypass ?? ''}
separator={separator}
disabled={!enabled}
error={bypassError}
helperText={
bypassError
? t('settings.modals.sysproxy.messages.invalidBypass')
: undefined
}
placeholder="localhost"
ariaLabel={t('settings.modals.sysproxy.fields.proxyBypass')}
onChange={(nextValue) => {
setValue((v) => ({ ...v, bypass: nextValue }))
}}
renderHeader={(modeToggle) => (
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.sysproxy.fields.proxyBypass')}
/>
{modeToggle ? (
<Box sx={{ marginLeft: 'auto' }}>{modeToggle}</Box>
) : null}
</ListItem>
)}
/>
)}
{!value.pac && value.use_default && (
<>
<ListItemText
primary={t('settings.modals.sysproxy.fields.bypass')}
/>
<Box sx={{ padding: '0 2px 5px' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{splitBypass(defaultBypass()).map((item) => (
<Chip key={item} label={item} size="small" />
))}
</Box>
</Box>
</>
)}
{value.pac && (
<ListItem sx={{ padding: '5px 2px', alignItems: 'start' }}>
<ListItemText
primary={t('settings.modals.sysproxy.fields.pacScriptContent')}
sx={{ padding: '3px 0' }}
/>
<Button
startIcon={<EditRounded />}
variant="outlined"
onClick={openPacEditor}
>
{t('settings.modals.sysproxy.actions.editPac')}
</Button>
{editorOpen && (
<EditorViewer
open={true}
title={t('settings.modals.sysproxy.actions.editPac')}
value={pacEditorValue}
language="javascript"
path="sysproxy-pac.js"
dirty={pacEditorValue !== pacEditorSavedValue}
onChange={setPacEditorValue}
onSave={handleSavePac}
onClose={() => setEditorOpen(false)}
/>
)}
</ListItem>
)}
</List>
</BaseDialog>
)
})
const FlexBox = styled('div')`
display: flex;
margin-top: 4px;
.label {
flex: none;
}
`