import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Form, Input, InputNumber, Modal, Radio, Select, Space, Tabs, message, } from 'antd'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields'; import { FormField, rhfZodValidate } from '@/components/form/rhf'; 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 { canEnableReality, canEnableStream, canEnableTls, canEnableTlsFlow, } from '@/lib/xray/protocol-capabilities'; import { FLOW_OPTIONS, HYSTERIA_NETWORK_OPTION, NETWORK_OPTIONS, PROTOCOL_OPTIONS, SERVER_PROTOCOLS, TARGET_STRATEGY_OPTIONS, } 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'; type StreamValue = OutboundFormValues['streamSettings']; interface OutboundFormModalProps { open: boolean; outbound: Record | null; existingTags: string[]; dialerProxyTags?: string[]; onClose: () => void; onConfirm: (outbound: Record) => void; } export default function OutboundFormModal({ open, outbound: outboundProp, existingTags, dialerProxyTags, onClose, onConfirm, }: OutboundFormModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const methods = useForm({ defaultValues: buildAddModeValues() }); const [activeKey, setActiveKey] = useState('1'); const [jsonText, setJsonText] = useState(''); const [jsonDirty, setJsonDirty] = useState(false); const [linkInput, setLinkInput] = useState(''); 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'); const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string; const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string; const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ?? '') as string; const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ?? 'none') as string; const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string; const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' }); const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as string | undefined; 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 } }); /* * 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 = methods.getValues('tag'); if (!parsed.tag && currentTag) parsed.tag = currentTag; const next = rawOutboundToFormValues(parsed); methods.reset(next); setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2)); setJsonDirty(false); setLinkInput(''); messageApi.success('Link imported successfully'); switchTab('1'); } useEffect(() => { if (!open) return; const initial = outboundProp ? rawOutboundToFormValues(outboundProp) : buildAddModeValues(); methods.reset(initial); setActiveKey('1'); setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2)); setJsonDirty(false); }, [open, outboundProp, methods]); useEffect(() => { if (!streamAllowed) return; /* * Wireguard dials its own UDP — only finalmask/sockopt apply, never a * transport. Don't seed network 'tcp'; clear a leftover one (from a * protocol switch) so the transmission/security blocks stay hidden. */ if (protocol === 'wireguard') { if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue); return; } if (network) return; methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue); }, [streamAllowed, network, protocol, methods]); useEffect(() => { if (protocol !== 'hysteria') return; if (network === 'hysteria' && security === 'tls') return; const existing = (methods.getValues('streamSettings') ?? {}) as Record; const slice = hysteriaStreamSlice(); if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings; if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings; methods.setValue('streamSettings', slice as StreamValue); }, [protocol, network, security, methods]); useEffect(() => { if (protocol !== 'wireguard') return; const sk = (wgSecretKey ?? '').trim(); if (!sk) { methods.setValue('settings.pubKey', ''); return; } try { const { publicKey } = Wireguard.generateKeypair(sk); methods.setValue('settings.pubKey', publicKey); } catch { methods.setValue('settings.pubKey', ''); } }, [protocol, wgSecretKey, methods]); useEffect(() => { /* eslint-disable-next-line react-hooks/incompatible-library */ const sub = methods.watch((_value, { name, type }) => { if (name !== 'protocol' || type !== 'change') return; const nextProtocol = methods.getValues('protocol'); const next = rawOutboundToFormValues({ protocol: nextProtocol }); methods.setValue('settings', next.settings); if (nextProtocol === 'hysteria') { methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue); } else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') { methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue); } }); return () => sub.unsubscribe(); }, [methods]); function onSecurityChange(next: string) { const stream = (methods.getValues('streamSettings') ?? {}) as Record; const cleaned = { ...stream }; 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; methods.setValue('streamSettings', cleaned as StreamValue); } /* * 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 = (methods.getValues('streamSettings') ?? {}) as Record; methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue); } function onXmuxToggle(checked: boolean) { if (!checked) return; const existing = methods.getValues('streamSettings.xhttpSettings.xmux'); const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0; if (hasValues) return; methods.setValue('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); methods.reset(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 = methods.getValues(); 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); methods.reset(values); setJsonDirty(false); } else { if (!(await methods.trigger())) return; values = methods.getValues(); } const tagValue = (values.tag ?? '').trim(); if (!tagValue) { messageApi.error(t('pages.xray.outboundForm.tagRequired')); return; } if (tagValue.startsWith('_bl_')) { messageApi.error(t('pages.xray.balancer.reservedPrefix')); 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}
field.onChange(e.target.value)} onBlur={field.onBlur} ref={field.ref} placeholder={t('pages.xray.outboundForm.tagPlaceholder')} /> ); }} /> {network === 'tcp' && } {network === 'kcp' && } {network === 'ws' && } {network === 'grpc' && } {network === 'httpupgrade' && } {network === 'xhttp' && } {network === 'hysteria' && } )} {tlsFlowAllowed && (