3x-ui/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
MHSanaei 4fd8a884cc
refactor(frontend): move HysteriaMasqueradeForm to lib/xray/forms/transport
The hysteria masquerade form edits streamSettings.hysteriaSettings.masquerade (a transport/stream concept) and is rendered identically by both modals, so it belongs next to FinalMaskForm in lib/xray/forms/transport/ rather than protocols/shared/. Moved the file, updated the transport barrel + both consumers (inbound hysteria protocol form, outbound modal), and removed the now-empty protocols/shared/ folder. Pure relocation; snapshots unchanged, typecheck/lint/build green.
2026-05-30 20:05:57 +02:00

962 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Switch,
Tabs,
message,
} from 'antd';
import { FinalMaskForm, HysteriaMasqueradeForm } from '@/lib/xray/forms/transport';
import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils';
import {
XMUX_DEFAULTS,
formValuesToWirePayload,
rawOutboundToFormValues,
} from '@/lib/xray/outbound-form-adapter';
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
import {
OutboundFormBaseSchema,
type OutboundFormValues,
} from '@/schemas/forms/outbound-form';
import {
DOMAIN_STRATEGY_OPTION,
SNIFFING_OPTION,
TCP_CONGESTION_OPTION,
} from '@/schemas/primitives';
import {
HappyEyeballsSchema,
SockoptStreamSettingsSchema,
} from '@/schemas/protocols/stream/sockopt';
import {
canEnableReality,
canEnableStream,
canEnableTls,
canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities';
import { antdRule } from '@/utils/zodForm';
import {
ADDRESS_PORT_STRATEGY_OPTIONS,
FLOW_OPTIONS,
HYSTERIA_NETWORK_OPTION,
NETWORK_OPTIONS,
PROTOCOL_OPTIONS,
SERVER_PROTOCOLS,
} from './outbound-form-constants';
import {
buildAddModeValues,
hysteriaStreamSlice,
isMuxAllowed,
newStreamSlice,
} from './outbound-form-helpers';
import {
BlackholeFields,
DnsFields,
FreedomFields,
HttpFields,
LoopbackFields,
ServerTarget,
ShadowsocksFields,
SocksFields,
TrojanFields,
VlessFields,
VmessFields,
WireguardFields,
} from './protocols';
import {
GrpcForm,
HttpUpgradeForm,
KcpForm,
RawForm,
WsForm,
XhttpForm,
} from './transport';
import { RealityForm, TlsForm } from './security';
import './OutboundFormModal.css';
// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
// file so the build stays green section-by-section. The atomic swap at
// the end of the rewrite replaces the legacy file in one commit
// (per Core Decision 7 in the migration spec).
interface OutboundFormModalProps {
open: boolean;
outbound: Record<string, unknown> | null;
existingTags: string[];
onClose: () => void;
onConfirm: (outbound: Record<string, unknown>) => void;
}
export default function OutboundFormModal({
open,
outbound: outboundProp,
existingTags,
onClose,
onConfirm,
}: OutboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [form] = Form.useForm<OutboundFormValues>();
const [activeKey, setActiveKey] = useState('1');
const [jsonText, setJsonText] = useState('');
const [jsonDirty, setJsonDirty] = useState(false);
const [linkInput, setLinkInput] = useState('');
// Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
// hysteria2:// / wireguard://) and replace form state with the result.
// The current tag is preserved when the parsed link doesn't carry one.
function importLink() {
const link = linkInput.trim();
if (!link) return;
const parsed = parseOutboundLink(link);
if (!parsed) {
messageApi.error('Wrong Link!');
return;
}
const currentTag = form.getFieldValue('tag') as string | undefined;
if (!parsed.tag && currentTag) parsed.tag = currentTag;
const next = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(next);
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
setJsonDirty(false);
setLinkInput('');
messageApi.success('Link imported successfully');
switchTab('1');
}
const isEdit = outboundProp != null;
const title = isEdit
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
useEffect(() => {
if (!open) return;
const initial = outboundProp
? rawOutboundToFormValues(outboundProp)
: buildAddModeValues();
form.resetFields();
form.setFieldsValue(initial);
setActiveKey('1');
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
setJsonDirty(false);
}, [open, outboundProp, form]);
const tag = Form.useWatch('tag', form) ?? '';
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
useEffect(() => {
if (!streamAllowed) return;
if (network) return;
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamAllowed, network]);
useEffect(() => {
if (protocol !== 'hysteria') return;
if (network === 'hysteria' && security === 'tls') return;
const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
const slice = hysteriaStreamSlice();
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
form.setFieldValue('streamSettings', slice);
}, [protocol, network, security]);
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
useEffect(() => {
if (protocol !== 'wireguard') return;
const sk = (wgSecretKey ?? '').trim();
if (!sk) {
form.setFieldValue(['settings', 'pubKey'], '');
return;
}
try {
const { publicKey } = Wireguard.generateKeypair(sk);
form.setFieldValue(['settings', 'pubKey'], publicKey);
} catch {
form.setFieldValue(['settings', 'pubKey'], '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [protocol, wgSecretKey]);
function onValuesChange(changed: Partial<OutboundFormValues>) {
if ('protocol' in changed && changed.protocol) {
const next = rawOutboundToFormValues({ protocol: changed.protocol });
form.setFieldValue('settings', next.settings);
if (changed.protocol === 'hysteria') {
form.setFieldValue('streamSettings', hysteriaStreamSlice());
} else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
}
}
}
function onSecurityChange(next: string) {
const stream = form.getFieldValue('streamSettings') ?? {};
const cleaned = { ...stream } as Record<string, unknown>;
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
cleaned.tlsSettings = {
serverName: '',
alpn: [],
fingerprint: '',
echConfigList: '',
verifyPeerCertByName: '',
pinnedPeerCertSha256: '',
};
} else if (next === 'reality') {
cleaned.realitySettings = {
publicKey: '',
fingerprint: 'chrome',
serverName: '',
shortId: '',
spiderX: '',
mldsa65Verify: '',
};
}
cleaned.security = next;
form.setFieldValue('streamSettings', cleaned);
}
// Network change cascade: swap the per-network sub-key (tcpSettings,
// wsSettings, etc.) so the DU branch matches. Preserve security if
// the new network supports it, otherwise force back to 'none'.
function onNetworkChange(next: string) {
if (next === 'hysteria') {
form.setFieldValue('streamSettings', hysteriaStreamSlice());
return;
}
const currentSecurity = form.getFieldValue(['streamSettings', 'security']) ?? 'none';
const stillAllowed = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
const newSecurity =
currentSecurity === 'tls' && !stillAllowed
? 'none'
: currentSecurity === 'reality' && !stillReality
? 'none'
: currentSecurity;
form.setFieldValue('streamSettings', { ...newStreamSlice(next), security: newSecurity });
}
function onXmuxToggle(checked: boolean) {
if (!checked) return;
const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
}
const duplicateTag = useMemo(() => {
const myTag = tag.trim();
if (!myTag) return false;
if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
return (existingTags || []).includes(myTag);
}, [tag, existingTags, isEdit, outboundProp]);
// Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
// any edits into form state. When entering JSON tab, snapshot current
// form values so the user sees the live shape.
function applyJsonToForm(): boolean {
if (!jsonDirty) return true;
const raw = jsonText.trim();
if (!raw) return true;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
const next = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(next);
setJsonDirty(false);
return true;
}
function switchTab(key: string) {
if (typeof document !== 'undefined') {
(document.activeElement as HTMLElement | null)?.blur?.();
}
setActiveKey(key);
}
function onTabChange(key: string) {
if (key === '2') {
const values = form.getFieldsValue(true) as OutboundFormValues;
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
setJsonDirty(false);
switchTab(key);
return;
}
if (key === '1' && activeKey === '2') {
if (!applyJsonToForm()) return;
}
switchTab(key);
}
async function onOk() {
let values: OutboundFormValues;
if (activeKey === '2') {
const raw = jsonText.trim();
if (!raw) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return;
}
values = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(values);
setJsonDirty(false);
} else {
try {
await form.validateFields();
} catch {
return;
}
values = form.getFieldsValue(true) as OutboundFormValues;
}
const tagValue = (values.tag ?? '').trim();
if (!tagValue) {
messageApi.error(t('pages.xray.outboundForm.tagRequired'));
return;
}
const isDuplicateTag = (existingTags || []).includes(tagValue)
&& !(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
if (isDuplicateTag) {
messageApi.error('Tag already used by another outbound');
return;
}
onConfirm(formValuesToWirePayload(values));
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={780}
onOk={onOk}
onCancel={onClose}
destroyOnHidden
>
<Form
form={form}
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
onValuesChange={onValuesChange}
>
<Tabs
activeKey={activeKey}
onChange={onTabChange}
items={[
{
key: '1',
label: t('pages.xray.basicTemplate'),
children: (
<>
<Form.Item
label={t('protocol')}
name="protocol"
rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
>
<Select options={PROTOCOL_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.xray.outbound.tag')}
name="tag"
validateStatus={duplicateTag ? 'warning' : undefined}
help={duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined}
rules={[
{ required: true, message: t('pages.xray.outboundForm.tagRequired') },
]}
>
<Input placeholder={t('pages.xray.outboundForm.tagPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
</Form.Item>
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
{protocol === 'vmess' && <VmessFields />}
{protocol === 'vless' && <VlessFields />}
{protocol === 'trojan' && <TrojanFields />}
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
{protocol === 'dns' && <DnsFields />}
{protocol === 'freedom' && <FreedomFields form={form} />}
{protocol === 'vless' && (
<Form.Item shouldUpdate noStyle>
{() => {
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
if (!reverseTag) return null;
const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as {
enabled?: boolean;
};
return (
<>
<Form.Item
label={t('pages.xray.outboundForm.reverseSniffing')}
name={['settings', 'reverseSniffing', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
{sniff.enabled && (
<>
<Form.Item
wrapperCol={{ md: { span: 14, offset: 8 } }}
name={['settings', 'reverseSniffing', 'destOverride']}
>
<Select
mode="multiple"
className="sniffing-options"
options={Object.entries(SNIFFING_OPTION).map(([k, v]) => ({
value: v,
label: k,
}))}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingMetadataOnly')}
name={['settings', 'reverseSniffing', 'metadataOnly']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingRouteOnly')}
name={['settings', 'reverseSniffing', 'routeOnly']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingIpsExcluded')}
name={['settings', 'reverseSniffing', 'ipsExcluded']}
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="IP/CIDR/geoip:*"
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingDomainsExcluded')}
name={['settings', 'reverseSniffing', 'domainsExcluded']}
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="domain:*"
/>
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
)}
{protocol === 'wireguard' && <WireguardFields form={form} />}
{streamAllowed && network && (
<>
<Form.Item
label={t('transmission')}
name={['streamSettings', 'network']}
>
<Select
value={network}
onChange={onNetworkChange}
options={
protocol === 'hysteria'
? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
{network === 'tcp' && <RawForm form={form} />}
{network === 'kcp' && <KcpForm />}
{network === 'ws' && <WsForm />}
{network === 'grpc' && <GrpcForm />}
{network === 'httpupgrade' && <HttpUpgradeForm />}
{network === 'xhttp' && <XhttpForm form={form} onXmuxToggle={onXmuxToggle} />}
{network === 'hysteria' && (
<>
<Form.Item
label={t('pages.inbounds.form.version')}
name={['streamSettings', 'hysteriaSettings', 'version']}
>
<InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.authPassword')}
name={['streamSettings', 'hysteriaSettings', 'auth']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.udpIdleTimeout')}
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<HysteriaMasqueradeForm form={form} />
</>
)}
</>
)}
{tlsFlowAllowed && (
<Form.Item label={t('pages.clients.flow')} name={['settings', 'flow']}>
<Select
allowClear
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
/>
</Form.Item>
)}
{/* Vision seed knobs only meaningful for the exact
xtls-rprx-vision flow, on TCP+(tls|reality). The
legacy class gated this on `canEnableVisionSeed()`
— same condition encoded inline here. */}
<Form.Item shouldUpdate noStyle>
{() => {
const flow =
(form.getFieldValue(['settings', 'flow']) ?? '') as string;
if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
return (
<>
<Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
<Space.Compact block>
{[900, 500, 900, 256].map((def, i) => (
<Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
<InputNumber min={1} style={{ width: '25%' }} />
</Form.Item>
))}
</Space.Compact>
</Form.Item>
</>
);
}}
</Form.Item>
{streamAllowed && network && (
<Form.Item label={t('security')}>
<Radio.Group
value={security}
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
)}
{security === 'tls' && tlsAllowed && <TlsForm />}
{security === 'reality' && realityAllowed && <RealityForm />}
{((streamAllowed && network) || !streamAllowed) && (
<Form.Item shouldUpdate noStyle>
{() => {
const hasSockopt = !!form.getFieldValue([
'streamSettings',
'sockopt',
]);
return (
<>
<Form.Item label={t('pages.xray.outboundForm.sockopts')}>
<Switch
checked={hasSockopt}
onChange={(checked) => {
form.setFieldValue(
['streamSettings', 'sockopt'],
checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasSockopt && (
<>
<Form.Item
label={t('pages.inbounds.form.dialerProxy')}
name={['streamSettings', 'sockopt', 'dialerProxy']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.xray.wireguard.domainStrategy')}
name={['streamSettings', 'sockopt', 'domainStrategy']}
>
<Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.addressPortStrategy')}
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
>
<Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.keepAliveInterval')}
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpFastOpen')}
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.multipathTcp')}
name={['streamSettings', 'sockopt', 'tcpMptcp']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.penetrate')}
name={['streamSettings', 'sockopt', 'penetrate']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.markFwmark')}
name={['streamSettings', 'sockopt', 'mark']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.interface')}
name={['streamSettings', 'sockopt', 'interfaceName']}
>
<Input />
</Form.Item>
<Form.Item
label="TProxy"
name={['streamSettings', 'sockopt', 'tproxy']}
>
<Select
options={[
{ value: 'off', label: 'off' },
{ value: 'redirect', label: 'redirect' },
{ value: 'tproxy', label: 'tproxy' },
]}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpCongestion')}
name={['streamSettings', 'sockopt', 'tcpcongestion']}
>
<Select
options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.ipv6Only')}
name={['streamSettings', 'sockopt', 'V6Only']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.acceptProxyProtocol')}
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpMaxSeg')}
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpWindowClamp')}
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.trustedXForwardedFor')}
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="trusted-proxy.example,10.0.0.0/8"
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const he = form.getFieldValue([
'streamSettings', 'sockopt', 'happyEyeballs',
]);
const hasHe = he != null;
return (
<>
<Form.Item label="Happy Eyeballs">
<Switch
checked={hasHe}
onChange={(v) => {
form.setFieldValue(
['streamSettings', 'sockopt', 'happyEyeballs'],
v ? HappyEyeballsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasHe && (
<>
<Form.Item
label={t('pages.inbounds.form.tryDelayMs')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="0 (disabled) — 250 recommended" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.prioritizeIPv6')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.interleave')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.maxConcurrentTry')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
<Button
type="dashed"
size="small"
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
>
+ {t('pages.inbounds.form.addCustomOption')}
</Button>
</Form.Item>
{fields.map((field) => (
<Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item name={[field.name, 'system']} noStyle>
<Select
placeholder="all"
allowClear
style={{ width: 100 }}
options={[
{ value: 'linux', label: 'linux' },
{ value: 'windows', label: 'windows' },
{ value: 'darwin', label: 'darwin' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'type']} noStyle>
<Select
style={{ width: 80 }}
options={[
{ value: 'int', label: 'int' },
{ value: 'str', label: 'str' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'level']} noStyle>
<Input placeholder="level (6=TCP)" style={{ width: 100 }} />
</Form.Item>
<Form.Item name={[field.name, 'opt']} noStyle>
<Input placeholder="opt (decimal)" style={{ width: 120 }} />
</Form.Item>
<Form.Item name={[field.name, 'value']} noStyle>
<Input placeholder="value" style={{ flex: 1 }} />
</Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space.Compact>
))}
</>
)}
</Form.List>
</>
)}
</>
);
}}
</Form.Item>
)}
<FinalMaskForm
name={['streamSettings', 'finalmask']}
network={network}
protocol={protocol}
form={form}
/>
{(() => {
const flow = (form.getFieldValue(['settings', 'flow']) ?? '') as string;
if (!isMuxAllowed(protocol, flow, network)) return null;
return (
<Form.Item shouldUpdate noStyle>
{() => {
const muxEnabled = !!form.getFieldValue(['mux', 'enabled']);
return (
<>
<Form.Item
label={t('pages.settings.mux')}
name={['mux', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
{muxEnabled && (
<>
<Form.Item
label={t('pages.settings.subFormats.concurrency')}
name={['mux', 'concurrency']}
>
<InputNumber min={-1} max={1024} />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.xudpConcurrency')}
name={['mux', 'xudpConcurrency']}
>
<InputNumber min={-1} max={1024} />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.xudpUdp443')}
name={['mux', 'xudpProxyUDP443']}
>
<Select
options={['reject', 'allow', 'skip'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
);
})()}
</>
),
},
{
key: '2',
label: 'JSON',
children: (
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
<Input.Search
value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
enterButton="Import"
onChange={(e) => setLinkInput(e.target.value)}
onSearch={importLink}
/>
<JsonEditor
value={jsonText}
onChange={(next) => {
setJsonText(next);
setJsonDirty(true);
}}
minHeight="360px"
maxHeight="600px"
/>
</Space>
),
},
]}
/>
</Form>
</Modal>
</>
);
}