From eac50b4e80f6fd388602d33c07c4e2da3ae76a09 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 12:20:37 +0200 Subject: [PATCH] feat(frontend): atomic swap OutboundFormModal to Pattern A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the legacy 1473-line class-based OutboundFormModal.tsx and replace it with the new Pattern A modal (Form.useForm + antdRule + per-protocol discriminated-union form values + wire adapter). Net diff: legacy file gone, function renamed from OutboundFormModalNew to OutboundFormModal so the existing OutboundsTab import resolves unchanged. What is migrated: - All 12 protocols (vmess/vless/trojan/ss/socks/http/wireguard/ hysteria/freedom/blackhole/dns/loopback) - Stream tab with TCP/KCP/WS/gRPC/HTTPUpgrade + partial XHTTP - Security tab with TLS + Reality + Flow gating - Sockopt + Mux sections (gated by isMuxAllowed) - JSON tab with bidirectional bridge to form state - Tag uniqueness check - VLESS reverse-sniffing slice - Freedom fragment/noises/finalRules - DNS rewrite + rules list - Wireguard peers + nested allowedIPs sub-list - Wireguard secret/public key regeneration Deferred to follow-up commits (still accessible via the JSON tab): - XHTTP advanced fields (xmux, sequence/session placement, padding obfs) - Hysteria stream transport sub-form - TCP HTTP camouflage host/path body - WS/HTTPUpgrade/XHTTP headers map editor - Remaining sockopt knobs (tcpUserTimeout, tcpcongestion, tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp, V6Only, trustedXForwardedFor, tproxy, acceptProxyProtocol) - VLESS Vision testpre/testseed - Reality API helpers (random target, x25519/mldsa65 generate-import) - Link import (vmess:// vless:// etc → outbound) - FinalMaskForm hookup (deferred from inbound rewrite too) --- .../src/pages/xray/OutboundFormModal.new.tsx | 1486 --------- frontend/src/pages/xray/OutboundFormModal.tsx | 2773 +++++++++-------- 2 files changed, 1393 insertions(+), 2866 deletions(-) delete mode 100644 frontend/src/pages/xray/OutboundFormModal.new.tsx diff --git a/frontend/src/pages/xray/OutboundFormModal.new.tsx b/frontend/src/pages/xray/OutboundFormModal.new.tsx deleted file mode 100644 index b8993dc6..00000000 --- a/frontend/src/pages/xray/OutboundFormModal.new.tsx +++ /dev/null @@ -1,1486 +0,0 @@ -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 InputAddon from '@/components/InputAddon'; -import JsonEditor from '@/components/JsonEditor'; -import { Wireguard } from '@/utils'; -import { - formValuesToWirePayload, - rawOutboundToFormValues, -} from '@/lib/xray/outbound-form-adapter'; -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, - 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' }, -]; - -// 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', - }, - }; - 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 OutboundFormModalNew({ - 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 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) ?? '') as string; - const security = (Form.useWatch(['streamSettings', 'security'], form) ?? '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]); - - // 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 }) => ( - <> - -