import { arrayMove } from '@dnd-kit/helpers' import { VerticalAlignBottomRounded, VerticalAlignTopRounded, } from '@mui/icons-material' import { Autocomplete, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, List, ListItem, ListItemText, TextField, styled, } from '@mui/material' import { useLockFn } from 'ahooks' import * as yaml from 'js-yaml' import { startTransition, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import { useTranslation } from 'react-i18next' import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base' import { RuleItem } from '@/components/profile/rule-item' import { readProfileFile, saveProfileFile } from '@/services/cmds' import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { useThemeMode } from '@/services/states' import type { TranslationKey } from '@/types/generated/i18n-keys' import type { MonacoEditorInstance } from '@/types/monaco' import { MONACO_FONT_FAMILY } from '@/utils/font-family' import getSystem from '@/utils/get-system' import { isValidIpCidr } from '@/utils/network' import { parseYamlSafe } from '@/utils/yaml' import { buildGroupedItems, type GroupedVirtualItem, GroupedVirtualList, } from './grouped-virtual-list' interface Props { groupsUid: string mergeUid: string profileUid: string property: string open: boolean onClose: () => void onSave?: (prev?: string, curr?: string) => void } const portValidator = (value: string): boolean => { return new RegExp( '^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$', ).test(value) } const rules: { name: string required?: boolean example?: string noResolve?: boolean validator?: (value: string) => boolean }[] = [ { name: 'DOMAIN', example: 'example.com', }, { name: 'DOMAIN-SUFFIX', example: 'example.com', }, { name: 'DOMAIN-KEYWORD', example: 'example', }, { name: 'DOMAIN-REGEX', example: 'example.*', }, { name: 'GEOSITE', example: 'youtube', }, { name: 'GEOIP', example: 'CN', noResolve: true, }, { name: 'SRC-GEOIP', example: 'CN', }, { name: 'IP-ASN', example: '13335', noResolve: true, validator: (value) => (+value ? true : false), }, { name: 'SRC-IP-ASN', example: '9808', validator: (value) => (+value ? true : false), }, { name: 'IP-CIDR', example: '127.0.0.0/8', noResolve: true, validator: isValidIpCidr, }, { name: 'IP-CIDR6', example: '2620:0:2d0:200::7/32', noResolve: true, validator: isValidIpCidr, }, { name: 'SRC-IP-CIDR', example: '192.168.1.201/32', validator: isValidIpCidr, }, { name: 'IP-SUFFIX', example: '8.8.8.8/24', noResolve: true, validator: isValidIpCidr, }, { name: 'SRC-IP-SUFFIX', example: '192.168.1.201/8', validator: isValidIpCidr, }, { name: 'SRC-PORT', example: '7777', validator: (value) => portValidator(value), }, { name: 'DST-PORT', example: '80', validator: (value) => portValidator(value), }, { name: 'IN-PORT', example: '7897', validator: (value) => portValidator(value), }, { name: 'DSCP', example: '4', }, { name: 'PROCESS-NAME', example: getSystem() === 'windows' ? 'chrome.exe' : 'curl', }, { name: 'PROCESS-PATH', example: getSystem() === 'windows' ? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' : '/usr/bin/wget', }, { name: 'PROCESS-NAME-REGEX', example: '.*telegram.*', }, { name: 'PROCESS-PATH-REGEX', example: getSystem() === 'windows' ? '(?i).*Application\\chrome.*' : '.*bin/wget', }, { name: 'NETWORK', example: 'udp', validator: (value) => ['tcp', 'udp'].includes(value), }, { name: 'UID', example: '1001', validator: (value) => (+value ? true : false), }, { name: 'IN-TYPE', example: 'SOCKS/HTTP', }, { name: 'IN-USER', example: 'mihomo', }, { name: 'IN-NAME', example: 'ss', }, { name: 'SUB-RULE', example: '(NETWORK,tcp)', }, { name: 'RULE-SET', example: 'providername', noResolve: true, }, { name: 'AND', example: '((DOMAIN,baidu.com),(NETWORK,UDP))', }, { name: 'OR', example: '((NETWORK,UDP),(DOMAIN,baidu.com))', }, { name: 'NOT', example: '((DOMAIN,baidu.com))', }, { name: 'MATCH', required: false, }, ] const RULE_TYPE_LABEL_KEYS: Record = Object.fromEntries( rules.map((rule) => [ rule.name, `rules.modals.editor.ruleTypes.${rule.name}`, ]), ) const builtinProxyPolicies = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS'] const PROXY_POLICY_LABEL_KEYS: Record = builtinProxyPolicies.reduce( (acc, policy) => { acc[policy] = `proxies.components.enums.policies.${policy}` as TranslationKey return acc }, {} as Record, ) const findRealIndex = ( list: string[], filtered: string[], filteredIndex: number, ): number => { const item = filtered[filteredIndex] if (item === undefined) return -1 return list.indexOf(item) } export const RulesEditorViewer = (props: Props) => { const { groupsUid, mergeUid, profileUid, property, open, onClose, onSave } = props const { t } = useTranslation() const themeMode = useThemeMode() const editorRef = useRef(null) const [prevData, setPrevData] = useState('') const [currData, setCurrData] = useState('') const [visualization, setVisualization] = useState(true) const [match, setMatch] = useState(() => (_: string) => true) const [ruleType, setRuleType] = useState<(typeof rules)[number]>(rules[0]) const [ruleContent, setRuleContent] = useState('') const [noResolve, setNoResolve] = useState(false) const [proxyPolicy, setProxyPolicy] = useState(builtinProxyPolicies[0]) const [proxyPolicyList, setProxyPolicyList] = useState([]) const [ruleList, setRuleList] = useState([]) const [ruleSetList, setRuleSetList] = useState([]) const [subRuleList, setSubRuleList] = useState([]) const [prependSeq, setPrependSeq] = useState([]) const [appendSeq, setAppendSeq] = useState([]) const [deleteSeq, setDeleteSeq] = useState([]) const hasLoadedSeqConfigRef = useRef(false) const filteredPrependSeq = useMemo( () => prependSeq.filter((rule) => match(rule)), [prependSeq, match], ) const filteredRuleList = useMemo( () => ruleList.filter((rule) => match(rule)), [ruleList, match], ) const filteredAppendSeq = useMemo( () => appendSeq.filter((rule) => match(rule)), [appendSeq, match], ) const items = useMemo( () => buildGroupedItems( filteredPrependSeq, filteredRuleList, filteredAppendSeq, (rule) => rule, ), [filteredPrependSeq, filteredRuleList, filteredAppendSeq], ) const renderItem = (entry: GroupedVirtualItem) => { const { category, item } = entry if (category === 'original') { const isDeleted = deleteSeq.includes(item) return ( { if (isDeleted) { setDeleteSeq(deleteSeq.filter((v) => v !== item)) } else { setDeleteSeq((prev) => [...prev, item]) } }} /> ) } if (category === 'prepend') { return ( { setPrependSeq(prependSeq.filter((v) => v !== item)) }} onAppend={() => { setAppendSeq((prev) => prev.includes(item) ? prev : [...prev, item], ) setPrependSeq(prependSeq.filter((v) => v !== item)) }} /> ) } return ( { setAppendSeq(appendSeq.filter((v) => v !== item)) }} onPrepend={() => { setPrependSeq((prev) => prev.includes(item) ? prev : [...prev, item], ) setAppendSeq(appendSeq.filter((v) => v !== item)) }} /> ) } const onReorder = ( category: 'prepend' | 'append', activeIndex: number, overIndex: number, ) => { const list = category === 'prepend' ? prependSeq : appendSeq const filtered = category === 'prepend' ? filteredPrependSeq : filteredAppendSeq const setList = category === 'prepend' ? setPrependSeq : setAppendSeq const activeRealIndex = findRealIndex(list, filtered, activeIndex) const overRealIndex = findRealIndex(list, filtered, overIndex) if ( activeRealIndex < 0 || overRealIndex < 0 || activeRealIndex === overRealIndex ) { return } setList(arrayMove(list, activeRealIndex, overRealIndex)) } const fetchContent = useCallback(async () => { hasLoadedSeqConfigRef.current = false const data = await readProfileFile(property) const obj = parseYamlSafe(data) as ISeqProfileConfig | null | undefined setPrevData(data) setCurrData(data) if (obj === undefined) { setVisualization(false) return } setPrependSeq((obj?.prepend || []).filter((rule) => rule !== null)) setAppendSeq((obj?.append || []).filter((rule) => rule !== null)) setDeleteSeq((obj?.delete || []).filter((rule) => rule !== null)) hasLoadedSeqConfigRef.current = true }, [property]) const handleVisualizationToggle = () => { if (visualization) { setVisualization(false) return } const obj = parseYamlSafe(currData) as ISeqProfileConfig | null | undefined if (obj === undefined) { hasLoadedSeqConfigRef.current = false return } hasLoadedSeqConfigRef.current = true startTransition(() => { setPrependSeq((obj?.prepend ?? []).filter((rule) => rule !== null)) setAppendSeq((obj?.append ?? []).filter((rule) => rule !== null)) setDeleteSeq((obj?.delete ?? []).filter((rule) => rule !== null)) }) setVisualization(true) } // 优化:异步处理大数据yaml.dump,避免UI卡死 useEffect(() => { if (!hasLoadedSeqConfigRef.current) { return } if (!(prependSeq && appendSeq && deleteSeq)) { return } const serialize = () => { if (!hasLoadedSeqConfigRef.current) { return } try { setCurrData( yaml.dump( { prepend: prependSeq, append: appendSeq, delete: deleteSeq }, { forceQuotes: true }, ), ) } catch (error) { showNotice.error(error ?? 'YAML dump error') } } let idleId: number | undefined let timeoutId: number | undefined if (window.requestIdleCallback) { idleId = window.requestIdleCallback(serialize) } else { timeoutId = window.setTimeout(serialize, 0) } return () => { if (idleId !== undefined && window.cancelIdleCallback) { window.cancelIdleCallback(idleId) } if (timeoutId !== undefined) { clearTimeout(timeoutId) } } }, [prependSeq, appendSeq, deleteSeq]) const fetchProfile = useCallback(async () => { const data = await readProfileFile(profileUid) // 原配置文件 const groupsData = await readProfileFile(groupsUid) // groups配置文件 const mergeData = await readProfileFile(mergeUid) // merge配置文件 const globalMergeData = await readProfileFile('Merge') // global merge配置文件 const rulesObj = parseYamlSafe(data) as { rules: [] } | null const originGroupsObj = parseYamlSafe(data) as { 'proxy-groups': IProxyGroupConfig[] } | null const originGroups = originGroupsObj?.['proxy-groups'] || [] const moreGroupsObj = parseYamlSafe(groupsData) as ISeqProfileConfig | null const rawPrependGroups = moreGroupsObj?.['prepend'] const morePrependGroups = Array.isArray(rawPrependGroups) ? (rawPrependGroups as IProxyGroupConfig[]) : [] const rawAppendGroups = moreGroupsObj?.['append'] const moreAppendGroups = Array.isArray(rawAppendGroups) ? (rawAppendGroups as IProxyGroupConfig[]) : [] const rawDeleteGroups = moreGroupsObj?.['delete'] const moreDeleteGroups: Array = Array.isArray( rawDeleteGroups, ) ? (rawDeleteGroups as Array) : [] const groups = morePrependGroups.concat( originGroups.filter((group: any) => { if (group.name) { return !moreDeleteGroups.includes(group.name) } else { return !moreDeleteGroups.includes(group) } }), moreAppendGroups, ) const originRuleSetObj = parseYamlSafe(data) as { 'rule-providers': Record } | null const originRuleSet = originRuleSetObj?.['rule-providers'] || {} const moreRuleSetObj = parseYamlSafe(mergeData) as { 'rule-providers': Record } | null const moreRuleSet = moreRuleSetObj?.['rule-providers'] || {} const globalRuleSetObj = parseYamlSafe(globalMergeData) as { 'rule-providers': Record } | null const globalRuleSet = globalRuleSetObj?.['rule-providers'] || {} const ruleSet = Object.assign({}, originRuleSet, moreRuleSet, globalRuleSet) const originSubRuleObj = parseYamlSafe(data) as { 'sub-rules': Record } | null const originSubRule = originSubRuleObj?.['sub-rules'] || {} const moreSubRuleObj = parseYamlSafe(mergeData) as { 'sub-rules': Record } | null const moreSubRule = moreSubRuleObj?.['sub-rules'] || {} const globalSubRuleObj = parseYamlSafe(globalMergeData) as { 'sub-rules': Record } | null const globalSubRule = globalSubRuleObj?.['sub-rules'] || {} const subRule = Object.assign({}, originSubRule, moreSubRule, globalSubRule) setProxyPolicyList( builtinProxyPolicies.concat(groups.map((group: any) => group.name)), ) setRuleSetList(Object.keys(ruleSet)) setSubRuleList(Object.keys(subRule)) setRuleList(rulesObj?.rules || []) }, [groupsUid, mergeUid, profileUid]) useEffect(() => { if (!open) return fetchContent() fetchProfile() }, [fetchContent, fetchProfile, open]) useEffect(() => { return () => { editorRef.current?.dispose() editorRef.current = null } }, []) const validateRule = () => { if ((ruleType.required ?? true) && !ruleContent) { throw new Error( t('rules.modals.editor.form.validation.conditionRequired'), ) } if (ruleType.validator && !ruleType.validator(ruleContent)) { throw new Error(t('rules.modals.editor.form.validation.invalidRule')) } const condition = (ruleType.required ?? true) ? ruleContent : '' return `${ruleType.name}${condition ? ',' + condition : ''},${proxyPolicy}${ ruleType.noResolve && noResolve ? ',no-resolve' : '' }` } const handleSave = useLockFn(async () => { try { const result = await mutate(() => saveProfileFile(property, currData), { id: `save-profile-file:${property}`, errorNotice: false, }) if (!(result.ok && result.value === true)) { await fetchContent() onClose() return } showNotice.success('shared.feedback.notifications.saved') onSave?.(prevData, currData) onClose() } catch (err: any) { showNotice.error(err) } }) return ( { {t('rules.modals.editor.title')} } {visualization ? ( <> } options={rules} value={ruleType} getOptionLabel={(option) => t(RULE_TYPE_LABEL_KEYS[option.name] ?? option.name) } renderOption={(props, option) => { const { key, ...optionProps } = props const label = t( RULE_TYPE_LABEL_KEYS[option.name] ?? option.name, ) return (
  • {label}
  • ) }} onChange={(_, value) => value && setRuleType(value)} />
    {ruleType.name === 'RULE-SET' && ( } options={ruleSetList} value={ruleContent} onChange={(_, value) => value && setRuleContent(value)} /> )} {ruleType.name === 'SUB-RULE' && ( } options={subRuleList} value={ruleContent} onChange={(_, value) => value && setRuleContent(value)} /> )} {ruleType.name !== 'RULE-SET' && ruleType.name !== 'SUB-RULE' && ( setRuleContent(e.target.value)} /> )} } options={proxyPolicyList} value={proxyPolicy} getOptionLabel={(option) => t(PROXY_POLICY_LABEL_KEYS[option] ?? option) } renderOption={(props, option) => { const { key, ...optionProps } = props const label = t(PROXY_POLICY_LABEL_KEYS[option] ?? option) return (
  • {label}
  • ) }} onChange={(_, value) => value && setProxyPolicy(value)} />
    {ruleType.noResolve && ( setNoResolve(!noResolve)} /> )}
    setMatch(() => match)} /> ) : ( { editorRef.current = editorInstance }} options={{ tabSize: 2, // 根据语言类型设置缩进大小 minimap: { enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条 }, mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例 quickSuggestions: { strings: true, // 字符串类型的建议 comments: true, // 注释类型的建议 other: true, // 其他类型的建议 }, padding: { top: 33, // 顶部padding防止遮挡snippets }, fontFamily: MONACO_FONT_FAMILY, fontLigatures: false, // 连字符 smoothScrolling: true, // 平滑滚动 }} onChange={(value) => setCurrData(value ?? '')} /> )}
    ) } const Item = styled(ListItem)(() => ({ padding: '5px 2px', }))