diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 23da8193..342771b9 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -42,9 +42,8 @@ export interface AllSetting { subEnableRouting: boolean; subEncrypt: boolean; subJsonEnable: boolean; - subJsonFragment: string; + subJsonFinalMask: string; subJsonMux: string; - subJsonNoises: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; @@ -129,9 +128,8 @@ export interface AllSettingView { subEnableRouting: boolean; subEncrypt: boolean; subJsonEnable: boolean; - subJsonFragment: string; + subJsonFinalMask: string; subJsonMux: string; - subJsonNoises: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index e64c26f9..dac5acdc 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -44,9 +44,8 @@ export const AllSettingSchema = z.object({ subEnableRouting: z.boolean(), subEncrypt: z.boolean(), subJsonEnable: z.boolean(), - subJsonFragment: z.string(), + subJsonFinalMask: z.string(), subJsonMux: z.string(), - subJsonNoises: z.string(), subJsonPath: z.string(), subJsonRules: z.string(), subJsonURI: z.string(), @@ -132,9 +131,8 @@ export const AllSettingViewSchema = z.object({ subEnableRouting: z.boolean(), subEncrypt: z.boolean(), subJsonEnable: z.boolean(), - subJsonFragment: z.string(), + subJsonFinalMask: z.string(), subJsonMux: z.string(), - subJsonNoises: z.string(), subJsonPath: z.string(), subJsonRules: z.string(), subJsonURI: z.string(), diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index f400620f..457f9a85 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; import { OutboundProtocols } from '@/schemas/primitives'; -// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute -// paths under `name`; the parent modal owns the Form instance. -// -// Naming convention inside Form.List: AntD prefixes Form.Item `name` -// with the Form.List's own `name`. So Form.Items inside the render -// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested -// Form.Lists also use relative names. Using absolute paths here would -// double up the prefix and silently route reads/writes to the wrong -// storage path. - export interface FinalMaskFormProps { name: NamePath; network: string; protocol: string; form: FormInstance; + // When true, all sections (TCP / UDP / QUIC) are shown regardless of + // network/protocol. Used by the global sub-JSON finalmask editor where + // the masks apply to every stream rather than one specific transport. + showAll?: boolean; } const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; @@ -99,12 +93,12 @@ function defaultUdpHop(): Record { return { ports: '20000-50000', interval: '5-10' }; } -export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) { +export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) { const base = asPath(name); const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; - const showTcp = TCP_NETWORKS.includes(network); - const showUdp = isHysteria || network === 'kcp'; - const showQuic = isHysteria || network === 'xhttp'; + const showTcp = showAll || TCP_NETWORKS.includes(network); + const showUdp = showAll || isHysteria || network === 'kcp'; + const showQuic = showAll || isHysteria || network === 'xhttp'; const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const hasQuicParams = quicParams != null; @@ -392,13 +386,13 @@ function UdpMaskItem({ const options = isHysteria ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] : [ - { value: 'mkcp-legacy', label: 'mKCP Legacy' }, - { value: 'xdns', label: 'xDNS' }, - { value: 'xicmp', label: 'xICMP' }, - { value: 'realm', label: 'Realm' }, - { value: 'header-custom', label: 'Header Custom' }, - { value: 'noise', label: 'Noise' }, - ]; + { value: 'mkcp-legacy', label: 'mKCP Legacy' }, + { value: 'xdns', label: 'xDNS' }, + { value: 'xicmp', label: 'xICMP' }, + { value: 'realm', label: 'Realm' }, + { value: 'header-custom', label: 'Header Custom' }, + { value: 'noise', label: 'Noise' }, + ]; return (
diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index fcbe1ec1..f9c25828 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -55,10 +55,9 @@ export class AllSetting { subURI = ''; subJsonURI = ''; subClashURI = ''; - subJsonFragment = ''; - subJsonNoises = ''; subJsonMux = ''; subJsonRules = ''; + subJsonFinalMask = ''; timeLocation = 'Local'; diff --git a/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx b/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx new file mode 100644 index 00000000..09f588a7 --- /dev/null +++ b/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react'; +import { Form } from 'antd'; + +import { FinalMaskForm } from '@/lib/xray/forms/transport'; +import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask'; + +interface SubJsonFinalMaskFormProps { + value: string; + onChange: (next: string) => void; +} + +function hasValue(v: unknown): boolean { + if (v == null) return false; + if (Array.isArray(v)) return v.some(hasValue); + if (typeof v === 'object') return Object.values(v as Record).some(hasValue); + if (typeof v === 'string') return v.length > 0; + return true; +} + +function parseFinalMask(raw: string): FinalMaskStreamSettings { + try { + if (raw) return JSON.parse(raw) as FinalMaskStreamSettings; + } catch { + return { tcp: [], udp: [] }; + } + return { tcp: [], udp: [] }; +} + +export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) { + const [form] = Form.useForm(); + const [initial] = useState(() => parseFinalMask(value)); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined; + + useEffect(() => { + if (finalmask === undefined) return; + const next = hasValue(finalmask) ? JSON.stringify(finalmask) : ''; + if (next !== value) onChangeRef.current(next); + }, [finalmask, value]); + + return ( +
+ + + ); +} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx index 7cfe45f1..14c9aafe 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx @@ -1,8 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { - Button, - Card, Input, InputNumber, Select, @@ -10,19 +8,17 @@ import { Tabs, } from 'antd'; import { - DeleteOutlined, PartitionOutlined, - PlusOutlined, - ScissorOutlined, + RocketOutlined, SendOutlined, SettingOutlined, - ThunderboltOutlined, } from '@ant-design/icons'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; import { sanitizePath, normalizePath } from './uriPath'; +import SubJsonFinalMaskForm from './SubJsonFinalMaskForm'; import './SubscriptionFormatsTab.css'; interface SubscriptionFormatsTabProps { @@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps { updateSetting: (patch: Partial) => void; } -const DEFAULT_FRAGMENT = { - packets: 'tlshello', - length: '100-200', - interval: '10-20', - maxSplit: '300-400', -}; -const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [ - { type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }, -]; const DEFAULT_MUX = { enabled: true, concurrency: 8, @@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su const { t } = useTranslation(); const { isMobile } = useMediaQuery(); - const fragment = allSetting.subJsonFragment !== ''; - const noisesEnabled = allSetting.subJsonNoises !== ''; const muxEnabled = allSetting.subJsonMux !== ''; const directEnabled = allSetting.subJsonRules !== ''; - const fragmentObj = useMemo( - () => (fragment ? readJson(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT), - [allSetting.subJsonFragment, fragment], - ); - - function setFragmentEnabled(v: boolean) { - updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' }); - } - - function setFragmentField(key: K, value: string) { - if (value === '') return; - const next = { ...fragmentObj, [key]: value }; - updateSetting({ subJsonFragment: JSON.stringify(next) }); - } - - const noisesArray = useMemo( - () => (noisesEnabled ? readJson(allSetting.subJsonNoises, DEFAULT_NOISES) : []), - [allSetting.subJsonNoises, noisesEnabled], - ); - - function setNoisesEnabled(v: boolean) { - updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' }); - } - - function setNoisesArray(next: typeof DEFAULT_NOISES) { - if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) }); - } - - function addNoise() { - setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]); - } - - function removeNoise(index: number) { - const next = [...noisesArray]; - next.splice(index, 1); - setNoisesArray(next); - } - - function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) { - const next = [...noisesArray]; - next[index] = { ...next[index], [field]: value }; - setNoisesArray(next); - } - const muxObj = useMemo( () => (muxEnabled ? readJson(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), [allSetting.subJsonMux, muxEnabled], @@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su }, { key: '2', - label: catTabLabel(, t('pages.settings.fragment'), isMobile), + label: catTabLabel(, t('pages.settings.subFormats.finalMask'), isMobile), children: ( <> - - - - {fragment && ( -
- - setFragmentField('packets', e.target.value)} /> - - - setFragmentField('length', e.target.value)} /> - - - setFragmentField('interval', e.target.value)} /> - - - setFragmentField('maxSplit', e.target.value)} /> - -
- )} + + updateSetting({ subJsonFinalMask: v })} + /> ), }, { key: '3', - label: catTabLabel(, t('pages.settings.subFormats.noises'), isMobile), - children: ( - <> - - - - {noisesEnabled && ( -
- {noisesArray.map((noise, index) => ( - 1 ? ( -