From e978428ca3189a8abaa4450d034b1175b531ff15 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 14:38:53 +0200 Subject: [PATCH] feat(frontend): FinalMaskForm rewrite to Pattern A + wire into both modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite FinalMaskForm.tsx from a class-coupled component (mutated stream.finalmask.tcp[] via .addTcpMask/.delTcpMask methods, notified parent via onChange callback) into a Pattern A sub-form: takes a NamePath base, a FormInstance, and the surrounding network/protocol, then composes Form.List + Form.Item at absolute paths under that base. All array structures use nested Form.List — tcp/udp mask arrays, the clients/servers groups in header-custom (Form.List of Form.List of ItemEditor), and the noise list. Type Selects use onChange to reset the settings sub-object via form.setFieldValue, mirroring the legacy changeMaskType behavior. The kcp.mtu side effect on xdns type change is preserved. Wired into both InboundFormModal and OutboundFormModal stream tabs, placed after the sockopt section. The component is the first Pattern A consumer of nested Form.List inside another Form.List, so it stands as the reference for future nested-array sub-forms. --- frontend/src/components/FinalMaskForm.tsx | 1105 ++++++++--------- .../src/pages/inbounds/InboundFormModal.tsx | 8 + frontend/src/pages/xray/OutboundFormModal.tsx | 8 + 3 files changed, 513 insertions(+), 608 deletions(-) diff --git a/frontend/src/components/FinalMaskForm.tsx b/frontend/src/components/FinalMaskForm.tsx index 961a2c22..6f4c86e2 100644 --- a/frontend/src/components/FinalMaskForm.tsx +++ b/frontend/src/components/FinalMaskForm.tsx @@ -1,532 +1,503 @@ -import { useMemo } from 'react'; import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import type { FormInstance } from 'antd/es/form'; +import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; import { OutboundProtocols } from '@/schemas/primitives'; -interface StreamShape { - network?: string; - kcp?: { mtu?: number }; - finalmask: { - tcp?: MaskRow[]; - udp?: MaskRow[]; - enableQuicParams?: boolean; - quicParams?: QuicParams; - }; - addTcpMask: (type?: string) => void; - delTcpMask: (index: number) => void; - addUdpMask: (type?: string) => void; - delUdpMask: (index: number) => void; -} +// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute +// paths under `name`; the parent modal owns the Form instance and the +// surrounding layout. The legacy class-coupled component (which mutated +// `stream.finalmask.*` via .addTcpMask/.delTcpMask methods) is gone — all +// state lives in the parent form values, accessed via the `form` and +// `name` props. -interface MaskRow { - type: string; - settings: Record; - _getDefaultSettings: (type: string, settings: Record) => Record; -} - -interface ItemRow { - type: string; - packet: string | unknown[]; - delay?: number | string; - rand?: number | string; - randRange?: string; -} - -interface QuicParams { - congestion: string; - debug?: boolean; - brutalUp?: number | string; - brutalDown?: number | string; - hasUdpHop?: boolean; - udpHop?: { ports: string; interval: string | number }; - maxIdleTimeout?: number; - keepAlivePeriod?: number; - disablePathMTUDiscovery?: boolean; - maxIncomingStreams?: number; - initStreamReceiveWindow?: number; - maxStreamReceiveWindow?: number; - initConnectionReceiveWindow?: number; - maxConnectionReceiveWindow?: number; -} - -interface FinalMaskFormProps { - stream: StreamShape; +export interface FinalMaskFormProps { + name: NamePath; + network: string; protocol: string; - onChange: () => void; + form: FormInstance; } -function changeMaskType(mask: MaskRow, type: string) { - mask.type = type; - mask.settings = mask._getDefaultSettings(type, {}); +const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; + +function asPath(name: NamePath): (string | number)[] { + return Array.isArray(name) ? [...name] : [name]; } -function changeItemType(item: ItemRow, type: string) { - item.type = type; - if (type === 'base64') item.packet = RandomUtil.randomBase64(); - else if (type === 'array') { - item.rand = 0; - item.packet = []; - } else item.packet = ''; +function defaultTcpMaskSettings(type: string): Record { + switch (type) { + case 'fragment': + return { packets: '1-3', length: '', delay: '', maxSplit: '' }; + case 'sudoku': + return { + password: '', ascii: '', customTable: '', customTables: '', + paddingMin: 0, paddingMax: 0, + }; + case 'header-custom': + return { clients: [], servers: [] }; + default: + return {}; + } } -function newClientServerItem(): ItemRow { +function defaultUdpMaskSettings(type: string): Record { + switch (type) { + case 'salamander': + case 'mkcp-aes128gcm': + return { password: '' }; + case 'header-dns': + return { domain: '' }; + case 'xdns': + return { domains: [] }; + case 'xicmp': + return { ip: '0.0.0.0', id: 0 }; + case 'header-custom': + return { client: [], server: [] }; + case 'noise': + return { reset: 0, noise: [] }; + default: + return {}; + } +} + +function defaultClientServerItem(): Record { return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] }; } -function newUdpClientServerItem(): ItemRow { +function defaultUdpClientServerItem(): Record { return { rand: 0, randRange: '0-255', type: 'array', packet: [] }; } -function newNoiseItem(): ItemRow { - return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' }; +function defaultNoiseItem(): Record { + return { + rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20', + }; } -export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskFormProps) { - const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; - const network = stream?.network || ''; +function defaultQuicParams(): Record { + return { + congestion: 'bbr', + debug: false, + udpHop: { ports: '20000-50000', interval: 5 }, + }; +} - const showTcp = useMemo( - () => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network), - [network], - ); +export default function FinalMaskForm({ name, network, protocol, form }: 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'; - - function notify() { - onChange(); - } - - function changeUdpMaskType(mask: MaskRow, type: string) { - changeMaskType(mask, type); - if (network === 'kcp' && stream.kcp) { - stream.kcp.mtu = type === 'xdns' ? 900 : 1350; - } - notify(); - } - - function addUdpMaskWithDefault() { - const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm'; - stream.addUdpMask(def); - notify(); - } - - const tcpMasks = stream.finalmask.tcp || []; - const udpMasks = stream.finalmask.udp || []; + const enableQuic = Form.useWatch([...base, 'enableQuicParams'], form); if (!showTcp && !showUdp && !showQuic) return null; return ( -
- {showTcp && ( + <> + {showTcp && } + {showUdp && } + {showQuic && ( + <> + + { + if (v) { + const current = form.getFieldValue([...base, 'quicParams']); + if (!current) form.setFieldValue([...base, 'quicParams'], defaultQuicParams()); + } + }} + /> + + {enableQuic && } + + )} + + ); +} + +function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) { + return ( + + {(fields, { add, remove }) => ( <>