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 { DeleteOutlined, MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons'; import FinalMaskForm from '@/components/FinalMaskForm'; import HeaderMapEditor from '@/components/HeaderMapEditor'; import InputAddon from '@/components/InputAddon'; import JsonEditor from '@/components/JsonEditor'; import { Wireguard } from '@/utils'; import { formValuesToWirePayload, rawOutboundToFormValues, } from '@/lib/xray/outbound-form-adapter'; import { parseOutboundLink } from '@/lib/xray/outbound-link-parser'; import { OutboundFormBaseSchema, ShadowsocksOutboundFormSettingsSchema, TrojanOutboundFormSettingsSchema, VlessOutboundFormSettingsSchema, VmessOutboundFormSettingsSchema, type OutboundFormValues, } from '@/schemas/forms/outbound-form'; import { ALPN_OPTION, Address_Port_Strategy, DNSRuleActions, MODE_OPTION, OutboundDomainStrategies, OutboundProtocols as Protocols, SNIFFING_OPTION, TCP_CONGESTION_OPTION, TLS_FLOW_CONTROL, USERS_SECURITY, UTLS_FINGERPRINT, WireguardDomainStrategy, } from '@/schemas/primitives'; import { canEnableReality, canEnableStream, canEnableTls, canEnableTlsFlow, } from '@/lib/xray/protocol-capabilities'; import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks'; import { antdRule } from '@/utils/zodForm'; 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; } const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const SECURITY_OPTIONS = Object.values(USERS_SECURITY).map((v) => ({ value: v, label: v })); const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL).map((v) => ({ value: v, label: v })); const SS_METHOD_OPTIONS = SSMethodSchema.options.map((v) => ({ value: v, label: v })); const MODE_OPTIONS = Object.values(MODE_OPTION).map((v) => ({ value: v, label: v })); const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })); const ALPN_OPTIONS = Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })); const ADDRESS_PORT_STRATEGY_OPTIONS = Object.values(Address_Port_Strategy).map((v) => ({ value: v, label: v, })); // canEnableMux mirrors the adapter's helper but lives here so the modal // can show/hide the Mux section without going through the adapter. const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']); function isMuxAllowed(protocol: string, flow: string, network: string): boolean { if (!MUX_PROTOCOLS.has(protocol)) return false; if (protocol === 'vless' && flow) return false; if (network === 'xhttp') return false; return true; } const NETWORK_OPTIONS: { value: string; label: string }[] = [ { value: 'tcp', label: 'TCP (RAW)' }, { value: 'kcp', label: 'mKCP' }, { value: 'ws', label: 'WebSocket' }, { value: 'grpc', label: 'gRPC' }, { value: 'httpupgrade', label: 'HTTPUpgrade' }, { value: 'xhttp', label: 'XHTTP' }, ]; // Hysteria appends an extra `hysteria` network branch to the selector // — only when the parent protocol is hysteria. Wire-side this matches // the legacy modal's `isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS`. const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' }; // Per-network bootstrap. Mirrors the legacy class constructors so the // initial state for each transport matches what xray-core expects. function newStreamSlice(network: string): Record { switch (network) { case 'tcp': return { network: 'tcp', tcpSettings: { header: { type: 'none' } } }; case 'kcp': return { network: 'kcp', kcpSettings: { mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20, cwndMultiplier: 1, maxSendingWindow: 2097152, }, }; case 'ws': return { network: 'ws', wsSettings: { path: '/', host: '', headers: {}, heartbeatPeriod: 0 }, }; case 'grpc': return { network: 'grpc', grpcSettings: { serviceName: '', authority: '', multiMode: false }, }; case 'httpupgrade': return { network: 'httpupgrade', httpupgradeSettings: { path: '/', host: '', headers: {} }, }; case 'xhttp': return { network: 'xhttp', xhttpSettings: { path: '/', host: '', mode: '', headers: [], xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000', }, }; case 'hysteria': return { network: 'hysteria', hysteriaSettings: { version: 2, auth: '', udpIdleTimeout: 60, }, }; default: return { network: 'tcp', tcpSettings: { header: { type: 'none' } } }; } } // Protocols whose form schema carries a flat connect target — these all // get the shared "server" sub-block (address + port) at the top of the // protocol section. Wireguard has an address but no port. DNS/freedom/ // blackhole/loopback have no connect target. const SERVER_PROTOCOLS = new Set([ 'vmess', 'vless', 'trojan', 'shadowsocks', 'socks', 'http', 'hysteria', ]); function buildAddModeValues(): OutboundFormValues { return rawOutboundToFormValues({}); } 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://) 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'); setActiveKey('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; // preserve: true — without it useWatch only reflects values whose // Form.Item is currently mounted. The streamSettings selectors live // INSIDE `{streamAllowed && network && (...)}`, so the moment that // conditional gates them out, useWatch returns undefined, the gate // keeps returning false, and the stream block never renders even // though streamSettings is in the form store. 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 } }); // Seed streamSettings when the user picks a protocol that supports // streams but the form does not yet have a stream slice (new outbound, // or wire payload arrived without streamSettings). useEffect(() => { if (!streamAllowed) return; if (network) return; form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [streamAllowed, network]); // Wireguard pubKey is a UI-only field derived from secretKey on every // edit. The legacy modal did the same on every keystroke. We re-derive // here so paste-in secret keys immediately surface the matching pub. 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]); // Switching protocol resets the settings sub-object to fresh defaults // so leftover fields from the previous protocol do not bleed through. // The adapter's rawOutboundToFormValues seeds whatever the new protocol // expects (vless flat shape, vmess flat shape, wireguard with secretKey // placeholder, etc.). function onValuesChange(changed: Partial) { if ('protocol' in changed && changed.protocol) { const next = rawOutboundToFormValues({ protocol: changed.protocol }); form.setFieldValue('settings', next.settings); } } // Security change cascade: swap the security sub-key so the DU branch // matches. Seed default field values when entering tls/reality so the // sub-forms render without `undefined` field references. 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 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 }); } 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 onTabChange(key: string) { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } if (key === '2') { const values = form.getFieldsValue(true) as OutboundFormValues; setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2)); setJsonDirty(false); setActiveKey(key); return; } if (key === '1' && activeKey === '2') { if (!applyJsonToForm()) return; } setActiveKey(key); } async function onOk() { if (activeKey === '2' && !applyJsonToForm()) return; let values: OutboundFormValues; try { values = await form.validateFields(); } catch { return; } if (duplicateTag) { messageApi.error('Tag already used by another outbound'); return; } onConfirm(formValuesToWirePayload(values)); } return ( <> {messageContextHolder}
{/* Shared connect target (address + port) for protocols whose form schema carries them flat at settings root. Hidden for freedom/blackhole/dns/loopback/wireguard. */} {SERVER_PROTOCOLS.has(protocol) && ( <> )} {(protocol === 'vmess' || protocol === 'vless') && ( )} {protocol === 'vmess' && ( )} {(protocol === 'trojan' || protocol === 'shadowsocks') && ( )} {protocol === 'shadowsocks' && ( <> )} {protocol === 'hysteria' && ( )} {protocol === 'loopback' && ( )} {protocol === 'blackhole' && ( {(fields, { add, remove }) => ( <>