import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Form, Input, InputNumber, Modal, Radio, Select, Space, Switch, Tabs, message, } from 'antd'; import { FinalMaskForm } 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 { SNIFFING_OPTION } from '@/schemas/primitives'; import { canEnableReality, canEnableStream, canEnableTls, canEnableTlsFlow, } from '@/lib/xray/protocol-capabilities'; import { antdRule } from '@/utils/zodForm'; import { FLOW_OPTIONS, HYSTERIA_NETWORK_OPTION, NETWORK_OPTIONS, PROTOCOL_OPTIONS, SERVER_PROTOCOLS, } from './outbound-form-constants'; import { applyNetworkChange, buildAddModeValues, hysteriaStreamSlice, newStreamSlice, } from './outbound-form-helpers'; import { BlackholeFields, DnsFields, FreedomFields, HttpFields, LoopbackFields, ServerTarget, ShadowsocksFields, SocksFields, TrojanFields, VlessFields, VmessFields, WireguardFields, } from './protocols'; import { GrpcForm, HttpUpgradeForm, HysteriaForm, KcpForm, MuxForm, RawForm, SockoptForm, 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 | null; existingTags: string[]; onClose: () => void; onConfirm: (outbound: Record) => void; } export default function OutboundFormModal({ open, outbound: outboundProp, existingTags, onClose, onConfirm, }: OutboundFormModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const [form] = Form.useForm(); 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; const slice = hysteriaStreamSlice(); if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings; if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings; form.setFieldValue('streamSettings', slice); }, [protocol, network, security, form]); 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) { 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; 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) { const stream = (form.getFieldValue('streamSettings') ?? {}) as Record; form.setFieldValue('streamSettings', applyNetworkChange(protocol, stream, next)); } 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; try { parsed = JSON.parse(raw) as Record; } 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; try { parsed = JSON.parse(raw) as Record; } 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}
{SERVER_PROTOCOLS.has(protocol) && } {protocol === 'vmess' && } {protocol === 'vless' && } {protocol === 'trojan' && } {protocol === 'shadowsocks' && } {protocol === 'http' && } {protocol === 'socks' && } {protocol === 'loopback' && } {protocol === 'blackhole' && } {protocol === 'dns' && } {protocol === 'freedom' && } {protocol === 'vless' && ( {() => { const reverseTag = form.getFieldValue(['settings', 'reverseTag']); if (!reverseTag) return null; const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as { enabled?: boolean; }; return ( <> {sniff.enabled && ( <> {network === 'tcp' && } {network === 'kcp' && } {network === 'ws' && } {network === 'grpc' && } {network === 'httpupgrade' && } {network === 'xhttp' && } {network === 'hysteria' && } )} {tlsFlowAllowed && (