fix(frontend): FinalMaskForm relative paths + network-switch defaults (B13/B14)

B13 — FinalMaskForm used absolute paths like
['streamSettings', 'finalmask', 'tcp', 0, 'type'] for Form.Item names
inside Form.List render props. AntD's Form.List prefixes Form.Item
names with the list's own name, so the actual storage path became
['streamSettings', 'finalmask', 'tcp', 'streamSettings', 'finalmask',
'tcp', 0, 'type'] — total nonsense. Symptoms: Type Select didn't show
the 'fragment' default after add(), and the sub-form for the picked
type never rendered (Fragment/Sudoku/HeaderCustom).

Rewrote FinalMaskForm to use RELATIVE names inside every Form.List
context (TCP/UDP outer list + nested clients/servers/noise inner
lists). Added a `listPath` prop on the items so the shouldUpdate
guard and the side-effect setFieldValue calls (resetting `settings`
when type changes) can still address the absolute path; the
displayed Form.Items use the relative form (`[fieldName, 'type']`).

Replaced top-level Form.useWatch on nested paths with
<Form.Item shouldUpdate> blocks reading via getFieldValue, same
pattern as the earlier B5 fix — Form.useWatch on paths inside
Form.List doesn't re-fire reliably in AntD 6.4.3.

B14 — Switching network (KCP, WS, gRPC, XHTTP, ...) seeded the
new XSettings blob as `{}` so every field showed as empty. The
legacy `newStreamSlice` populated mtu=1350, tti=20, etc. Restored
those defaults in onNetworkChange and seeded the initial
tcpSettings.header in buildAddModeValues so even the default TCP
state shows the HTTP-camouflage Switch in the correct off state
instead of an undefined header object.
This commit is contained in:
MHSanaei 2026-05-26 16:18:54 +02:00
parent f3c0a94d80
commit fbdc6cdf91
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 254 additions and 137 deletions

View file

@ -7,11 +7,14 @@ import { RandomUtil } from '@/utils';
import { OutboundProtocols } from '@/schemas/primitives'; import { OutboundProtocols } from '@/schemas/primitives';
// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute // Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
// paths under `name`; the parent modal owns the Form instance and the // paths under `name`; the parent modal owns the Form instance.
// surrounding layout. The legacy class-coupled component (which mutated //
// `stream.finalmask.*` via .addTcpMask/.delTcpMask methods) is gone — all // Naming convention inside Form.List: AntD prefixes Form.Item `name`
// state lives in the parent form values, accessed via the `form` and // with the Form.List's own `name`. So Form.Items inside the render
// `name` props. // 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 { export interface FinalMaskFormProps {
name: NamePath; name: NamePath;
@ -97,7 +100,7 @@ export default function FinalMaskForm({ name, network, protocol, form }: FinalMa
return ( return (
<> <>
{showTcp && <TcpMasksList base={base} form={form} />} {showTcp && <TcpMasksList base={base} form={form} />}
{showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} />} {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} network={network} />}
{showQuic && ( {showQuic && (
<> <>
<Form.Item label="QUIC Params" name={[...base, 'enableQuicParams']} valuePropName="checked"> <Form.Item label="QUIC Params" name={[...base, 'enableQuicParams']} valuePropName="checked">
@ -133,10 +136,10 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
{fields.map((field, mIdx) => ( {fields.map((field, mIdx) => (
<TcpMaskItem <TcpMaskItem
key={field.key} key={field.key}
base={base} fieldName={field.name}
index={field.name}
displayIndex={mIdx + 1} displayIndex={mIdx + 1}
form={form} form={form}
listPath={[...base, 'tcp']}
onRemove={() => remove(field.name)} onRemove={() => remove(field.name)}
/> />
))} ))}
@ -147,15 +150,18 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
} }
function TcpMaskItem({ function TcpMaskItem({
base, index, displayIndex, form, onRemove, fieldName, displayIndex, form, listPath, onRemove,
}: { }: {
base: (string | number)[]; fieldName: number;
index: number;
displayIndex: number; displayIndex: number;
form: FormInstance; form: FormInstance;
listPath: (string | number)[];
onRemove: () => void; onRemove: () => void;
}) { }) {
const path = [...base, 'tcp', index]; // Absolute path for setFieldValue side effects (resetting settings on
// type change). All Form.Item `name=` use RELATIVE paths within the
// outer Form.List context.
const absolutePath = [...listPath, fieldName];
return ( return (
<div> <div>
@ -164,9 +170,11 @@ function TcpMaskItem({
<DeleteOutlined className="danger-icon" onClick={onRemove} /> <DeleteOutlined className="danger-icon" onClick={onRemove} />
</Divider> </Divider>
<Form.Item label="Type" name={[...path, 'type']}> <Form.Item label="Type" name={[fieldName, 'type']}>
<Select <Select
onChange={(v) => form.setFieldValue([...path, 'settings'], defaultTcpMaskSettings(v))} onChange={(v) =>
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
}
options={[ options={[
{ value: 'fragment', label: 'Fragment' }, { value: 'fragment', label: 'Fragment' },
{ value: 'header-custom', label: 'Header Custom' }, { value: 'header-custom', label: 'Header Custom' },
@ -177,16 +185,18 @@ function TcpMaskItem({
<Form.Item <Form.Item
noStyle noStyle
shouldUpdate={(prev, curr) => shouldUpdate={(prev, curr) => {
(prev as Record<string, unknown>)[String(path[0])] !== (curr as Record<string, unknown>)[String(path[0])] const a = getDeep(prev, [...absolutePath, 'type']);
} const b = getDeep(curr, [...absolutePath, 'type']);
return a !== b;
}}
> >
{({ getFieldValue }) => { {({ getFieldValue }) => {
const type = getFieldValue([...path, 'type']) as string | undefined; const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
if (type === 'fragment') { if (type === 'fragment') {
return ( return (
<> <>
<Form.Item label="Packets" name={[...path, 'settings', 'packets']}> <Form.Item label="Packets" name={[fieldName, 'settings', 'packets']}>
<Select <Select
options={[ options={[
{ value: 'tlshello', label: 'tlshello' }, { value: 'tlshello', label: 'tlshello' },
@ -195,13 +205,13 @@ function TcpMaskItem({
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item label="Length" name={[...path, 'settings', 'length']}> <Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label="Delay" name={[...path, 'settings', 'delay']}> <Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label="Max Split" name={[...path, 'settings', 'maxSplit']}> <Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
<Input /> <Input />
</Form.Item> </Form.Item>
</> </>
@ -210,21 +220,27 @@ function TcpMaskItem({
if (type === 'sudoku') { if (type === 'sudoku') {
return ( return (
<> <>
<Form.Item label="Password" name={[...path, 'settings', 'password']}><Input /></Form.Item> <Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
<Form.Item label="ASCII" name={[...path, 'settings', 'ascii']}><Input /></Form.Item> <Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
<Form.Item label="Custom Table" name={[...path, 'settings', 'customTable']}><Input /></Form.Item> <Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
<Form.Item label="Custom Tables" name={[...path, 'settings', 'customTables']}><Input /></Form.Item> <Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
<Form.Item label="Padding Min" name={[...path, 'settings', 'paddingMin']}> <Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
<Form.Item label="Padding Max" name={[...path, 'settings', 'paddingMax']}> <Form.Item label="Padding Max" name={[fieldName, 'settings', 'paddingMax']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
</> </>
); );
} }
if (type === 'header-custom') { if (type === 'header-custom') {
return <HeaderCustomGroups base={[...path, 'settings']} form={form} />; return (
<HeaderCustomGroups
tcpFieldName={fieldName}
form={form}
absoluteSettingsPath={[...absolutePath, 'settings']}
/>
);
} }
return null; return null;
}} }}
@ -233,11 +249,29 @@ function TcpMaskItem({
); );
} }
function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: FormInstance }) { // Walks a deep object path safely. Used inside shouldUpdate which gets
// the whole form values blob; we need to compare a deep field across
// prev/curr without crashing on missing intermediates.
function getDeep(obj: unknown, path: (string | number)[]): unknown {
let cur: unknown = obj;
for (const key of path) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string | number, unknown>)[key];
}
return cur;
}
function HeaderCustomGroups({
tcpFieldName, form, absoluteSettingsPath,
}: {
tcpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return ( return (
<> <>
{(['clients', 'servers'] as const).map((groupKey) => ( {(['clients', 'servers'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[...base, groupKey]}> <Form.List key={groupKey} name={[tcpFieldName, 'settings', groupKey]}>
{(groups, { add: addGroup, remove: removeGroup }) => ( {(groups, { add: addGroup, remove: removeGroup }) => (
<> <>
<Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}> <Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
@ -254,7 +288,7 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1} {groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
<DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} /> <DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
</Divider> </Divider>
<Form.List name={[...base, groupKey, group.name]}> <Form.List name={[group.name]}>
{(items, { add: addItem, remove: removeItem }) => ( {(items, { add: addItem, remove: removeItem }) => (
<> <>
<Form.Item label="Items"> <Form.Item label="Items">
@ -267,8 +301,9 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F
{items.map((item) => ( {items.map((item) => (
<ItemEditor <ItemEditor
key={item.key} key={item.key}
base={[...base, groupKey, group.name, item.name]} fieldName={item.name}
form={form} form={form}
absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]}
delayMode="number" delayMode="number"
onRemove={() => removeItem(item.name)} onRemove={() => removeItem(item.name)}
/> />
@ -287,8 +322,8 @@ function HeaderCustomGroups({ base, form }: { base: (string | number)[]; form: F
} }
function UdpMasksList({ function UdpMasksList({
base, form, isHysteria, base, form, isHysteria, network,
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean }) { }: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; network: string }) {
return ( return (
<Form.List name={[...base, 'udp']}> <Form.List name={[...base, 'udp']}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
@ -307,11 +342,12 @@ function UdpMasksList({
{fields.map((field, mIdx) => ( {fields.map((field, mIdx) => (
<UdpMaskItem <UdpMaskItem
key={field.key} key={field.key}
base={base} fieldName={field.name}
index={field.name}
displayIndex={mIdx + 1} displayIndex={mIdx + 1}
form={form} form={form}
listPath={[...base, 'udp']}
isHysteria={isHysteria} isHysteria={isHysteria}
network={network}
onRemove={() => remove(field.name)} onRemove={() => remove(field.name)}
/> />
))} ))}
@ -322,24 +358,23 @@ function UdpMasksList({
} }
function UdpMaskItem({ function UdpMaskItem({
base, index, displayIndex, form, isHysteria, onRemove, fieldName, displayIndex, form, listPath, isHysteria, network, onRemove,
}: { }: {
base: (string | number)[]; fieldName: number;
index: number;
displayIndex: number; displayIndex: number;
form: FormInstance; form: FormInstance;
listPath: (string | number)[];
isHysteria: boolean; isHysteria: boolean;
network: string;
onRemove: () => void; onRemove: () => void;
}) { }) {
const path = [...base, 'udp', index]; const absolutePath = [...listPath, fieldName];
const type = Form.useWatch([...path, 'type'], form) as string | undefined;
const network = Form.useWatch([...base.slice(0, -1), 'network'], form) as string | undefined;
const onTypeChange = (v: string) => { const onTypeChange = (v: string) => {
form.setFieldValue([...path, 'settings'], defaultUdpMaskSettings(v)); form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
if (network === 'kcp') { if (network === 'kcp') {
const kcpPath = [...base.slice(0, -1), 'kcpSettings', 'mtu']; const kcpMtuPath = [...listPath.slice(0, -1), 'kcpSettings', 'mtu'];
form.setFieldValue(kcpPath, v === 'xdns' ? 900 : 1350); form.setFieldValue(kcpMtuPath, v === 'xdns' ? 900 : 1350);
} }
}; };
@ -367,55 +402,85 @@ function UdpMaskItem({
<DeleteOutlined className="danger-icon" onClick={onRemove} /> <DeleteOutlined className="danger-icon" onClick={onRemove} />
</Divider> </Divider>
<Form.Item label="Type" name={[...path, 'type']}> <Form.Item label="Type" name={[fieldName, 'type']}>
<Select onChange={onTypeChange} options={options} /> <Select onChange={onTypeChange} options={options} />
</Form.Item> </Form.Item>
{(type === 'mkcp-aes128gcm' || type === 'salamander') && ( <Form.Item
<Form.Item label="Password" name={[...path, 'settings', 'password']}> noStyle
shouldUpdate={(prev, curr) => getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])}
>
{({ getFieldValue }) => {
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
if (type === 'mkcp-aes128gcm' || type === 'salamander') {
return (
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
<Input placeholder="Obfuscation password" /> <Input placeholder="Obfuscation password" />
</Form.Item> </Form.Item>
)} );
}
{type === 'header-dns' && ( if (type === 'header-dns') {
<Form.Item label="Domain" name={[...path, 'settings', 'domain']}> return (
<Form.Item label="Domain" name={[fieldName, 'settings', 'domain']}>
<Input placeholder="e.g., www.example.com" /> <Input placeholder="e.g., www.example.com" />
</Form.Item> </Form.Item>
)} );
}
{type === 'xdns' && ( if (type === 'xdns') {
<Form.Item label="Domains" name={[...path, 'settings', 'domains']}> return (
<Form.Item label="Domains" name={[fieldName, 'settings', 'domains']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} /> <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item> </Form.Item>
)} );
}
{type === 'xicmp' && ( if (type === 'xicmp') {
return (
<> <>
<Form.Item label="IP" name={[...path, 'settings', 'ip']}> <Form.Item label="IP" name={[fieldName, 'settings', 'ip']}>
<Input placeholder="0.0.0.0" /> <Input placeholder="0.0.0.0" />
</Form.Item> </Form.Item>
<Form.Item label="ID" name={[...path, 'settings', 'id']}> <Form.Item label="ID" name={[fieldName, 'settings', 'id']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
</> </>
)} );
}
{type === 'header-custom' && ( if (type === 'header-custom') {
<UdpHeaderCustom base={[...path, 'settings']} form={form} /> return (
)} <UdpHeaderCustom
udpFieldName={fieldName}
{type === 'noise' && ( form={form}
<NoiseItems base={[...path, 'settings']} form={form} /> absoluteSettingsPath={[...absolutePath, 'settings']}
)} />
);
}
if (type === 'noise') {
return (
<NoiseItems
udpFieldName={fieldName}
form={form}
absoluteSettingsPath={[...absolutePath, 'settings']}
/>
);
}
return null;
}}
</Form.Item>
</div> </div>
); );
} }
function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: FormInstance }) { function UdpHeaderCustom({
udpFieldName, form, absoluteSettingsPath,
}: {
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return ( return (
<> <>
{(['client', 'server'] as const).map((groupKey) => ( {(['client', 'server'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[...base, groupKey]}> <Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
{(items, { add, remove }) => ( {(items, { add, remove }) => (
<> <>
<Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}> <Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
@ -433,8 +498,9 @@ function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: Form
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} /> <DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
</Divider> </Divider>
<ItemEditor <ItemEditor
base={[...base, groupKey, item.name]} fieldName={item.name}
form={form} form={form}
absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]}
onRemove={() => remove(item.name)} onRemove={() => remove(item.name)}
/> />
</div> </div>
@ -447,13 +513,19 @@ function UdpHeaderCustom({ base, form }: { base: (string | number)[]; form: Form
); );
} }
function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInstance }) { function NoiseItems({
udpFieldName, form, absoluteSettingsPath,
}: {
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return ( return (
<> <>
<Form.Item label="Reset" name={[...base, 'reset']}> <Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
<Form.List name={[...base, 'noise']}> <Form.List name={[udpFieldName, 'settings', 'noise']}>
{(items, { add, remove }) => ( {(items, { add, remove }) => (
<> <>
<Form.Item label="Noise"> <Form.Item label="Noise">
@ -471,8 +543,9 @@ function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInsta
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} /> <DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
</Divider> </Divider>
<ItemEditor <ItemEditor
base={[...base, 'noise', item.name]} fieldName={item.name}
form={form} form={form}
absoluteItemPath={[...absoluteSettingsPath, 'noise', item.name]}
delayMode="string" delayMode="string"
onRemove={() => remove(item.name)} onRemove={() => remove(item.name)}
/> />
@ -486,28 +559,28 @@ function NoiseItems({ base, form }: { base: (string | number)[]; form: FormInsta
} }
function ItemEditor({ function ItemEditor({
base, form, delayMode, onRemove: _onRemove, fieldName, form, absoluteItemPath, delayMode, onRemove: _onRemove,
}: { }: {
base: (string | number)[]; fieldName: number;
form: FormInstance; form: FormInstance;
absoluteItemPath: (string | number)[];
delayMode?: 'number' | 'string'; delayMode?: 'number' | 'string';
onRemove?: () => void; onRemove?: () => void;
}) { }) {
const type = Form.useWatch([...base, 'type'], form) as string | undefined;
const onTypeChange = (v: string) => { const onTypeChange = (v: string) => {
if (v === 'base64') form.setFieldValue([...base, 'packet'], RandomUtil.randomBase64()); if (v === 'base64') {
else if (v === 'array') { form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
form.setFieldValue([...base, 'rand'], delayMode === 'string' ? '1-8192' : 0); } else if (v === 'array') {
form.setFieldValue([...base, 'packet'], []); form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
form.setFieldValue([...absoluteItemPath, 'packet'], []);
} else { } else {
form.setFieldValue([...base, 'packet'], ''); form.setFieldValue([...absoluteItemPath, 'packet'], '');
} }
}; };
return ( return (
<> <>
<Form.Item label="Type" name={[...base, 'type']}> <Form.Item label="Type" name={[fieldName, 'type']}>
<Select <Select
onChange={onTypeChange} onChange={onTypeChange}
options={[ options={[
@ -520,46 +593,60 @@ function ItemEditor({
</Form.Item> </Form.Item>
{delayMode === 'number' && ( {delayMode === 'number' && (
<Form.Item label="Delay (ms)" name={[...base, 'delay']}> <Form.Item label="Delay (ms)" name={[fieldName, 'delay']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
)} )}
{delayMode === 'string' && ( {delayMode === 'string' && (
<Form.Item label="Delay" name={[...base, 'delay']}> <Form.Item label="Delay" name={[fieldName, 'delay']}>
<Input placeholder="10-20" /> <Input placeholder="10-20" />
</Form.Item> </Form.Item>
)} )}
{type === 'array' ? ( <Form.Item
noStyle
shouldUpdate={(prev, curr) => getDeep(prev, [...absoluteItemPath, 'type']) !== getDeep(curr, [...absoluteItemPath, 'type'])}
>
{({ getFieldValue }) => {
const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
if (type === 'array') {
return (
<> <>
<Form.Item label="Rand" name={[...base, 'rand']}> <Form.Item label="Rand" name={[fieldName, 'rand']}>
{delayMode === 'string' ? ( {delayMode === 'string' ? (
<Input placeholder="0 or 1-8192" /> <Input placeholder="0 or 1-8192" />
) : ( ) : (
<InputNumber min={0} /> <InputNumber min={0} />
)} )}
</Form.Item> </Form.Item>
<Form.Item label="Rand Range" name={[...base, 'randRange']}> <Form.Item label="Rand Range" name={[fieldName, 'randRange']}>
<Input placeholder="0-255" /> <Input placeholder="0-255" />
</Form.Item> </Form.Item>
</> </>
) : type === 'base64' ? ( );
}
if (type === 'base64') {
return (
<Form.Item label="Packet"> <Form.Item label="Packet">
<Input.Group compact> <Input.Group compact>
<Form.Item name={[...base, 'packet']} noStyle> <Form.Item name={[fieldName, 'packet']} noStyle>
<Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} /> <Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item> </Form.Item>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => form.setFieldValue([...base, 'packet'], RandomUtil.randomBase64())} onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
/> />
</Input.Group> </Input.Group>
</Form.Item> </Form.Item>
) : ( );
<Form.Item label="Packet" name={[...base, 'packet']}> }
return (
<Form.Item label="Packet" name={[fieldName, 'packet']}>
<Input placeholder="binary data" /> <Input placeholder="binary data" />
</Form.Item> </Form.Item>
)} );
}}
</Form.Item>
</> </>
); );
} }

View file

@ -261,7 +261,11 @@ function buildAddModeValues(): InboundFormValues {
return rawInboundToFormValues({ return rawInboundToFormValues({
protocol: 'vless', protocol: 'vless',
settings, settings,
streamSettings: { network: 'tcp', security: 'none' }, streamSettings: {
network: 'tcp',
security: 'none',
tcpSettings: { header: { type: 'none' } },
},
sniffing: SniffingSchema.parse({}), sniffing: SniffingSchema.parse({}),
port: RandomUtil.randomInteger(10000, 60000), port: RandomUtil.randomInteger(10000, 60000),
listen: '', listen: '',
@ -1296,10 +1300,36 @@ export default function InboundFormModal({
</> </>
); );
// Switching `network` swaps which per-network key (tcpSettings, wsSettings, // Switching `network` swaps which per-network key (tcpSettings,
// grpcSettings, ...) appears on the wire. We clear the previously selected // wsSettings, grpcSettings, ...) appears on the wire. Clear the old
// network's settings blob and seed a default empty object for the new one // network's blob and seed the new one with the schema defaults so the
// so AntD's Form.Items aren't pointed at undefined nested paths. // Form.Items inside it have valid initial values (KCP needs MTU=1350
// etc., not empty strings).
const newStreamSlice = (n: string): Record<string, unknown> => {
switch (n) {
case 'tcp':
return { header: { type: 'none' } };
case 'kcp':
return {
mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
congestion: false, readBufferSize: 2, writeBufferSize: 2,
header: { type: 'none' }, seed: '',
};
case 'ws':
return { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
case 'grpc':
return { serviceName: '', authority: '', multiMode: false };
case 'httpupgrade':
return { path: '/', host: '', headers: {} };
case 'xhttp':
return {
path: '/', host: '', mode: 'auto', headers: {},
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
};
default:
return {};
}
};
const onNetworkChange = (next: string) => { const onNetworkChange = (next: string) => {
const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings']; const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {}; const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
@ -1307,7 +1337,7 @@ export default function InboundFormModal({
for (const k of ALL) { for (const k of ALL) {
if (k !== `${next}Settings`) delete cleaned[k]; if (k !== `${next}Settings`) delete cleaned[k];
} }
cleaned[`${next}Settings`] = {}; cleaned[`${next}Settings`] = newStreamSlice(next);
form.setFieldValue('streamSettings', cleaned); form.setFieldValue('streamSettings', cleaned);
}; };