Просмотр исходного кода

feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2)

The legacy outbound modal could import a vmess://, vless://, trojan://,
ss://, or hysteria2:// share link via a Convert button on the JSON
tab. Restore that UX with a focused pure-function parser.

lib/xray/outbound-link-parser.ts:
- parseVmessLink: base64 JSON, maps net/tls + per-network params onto
  the discriminated stream branch.
- parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow
  query params, dispatches transport via buildStream + applies
  security params via applySecurityParams.
- parseTrojanLink: same URL pattern, defaults security to tls.
- parseShadowsocksLink: both modern (base64 userinfo@host:port) and
  legacy (base64 of whole thing) ss:// formats.
- parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes,
  uses the hysteria stream branch with version=2 + TLS h3.
- parseOutboundLink dispatcher returns the first non-null parser
  result, or null when no scheme matches.

test/outbound-link-parser.test.ts:
- 13 cases covering happy paths for each protocol family plus malformed
  input, ss:// dual-format handling, hy2:// alias.

OutboundFormModal.tsx:
- Import button on the JSON tab Input.Search; on success, parsed
  payload flows through rawOutboundToFormValues, the form is reset,
  and we switch back to the Basic tab.
- Tag is preserved when the parsed link does not carry one.

Out of scope: advanced fields the legacy parser handled (xmux, padding
obfs, reality short IDs, finalmask from fm= param). Power users can
finish the import in the form after the basics land.
MHSanaei 7 часов назад
Родитель
Сommit
9de527b35f

+ 345 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -0,0 +1,345 @@
+import { Base64 } from '@/utils';
+
+// Focused share-link parser for the OutboundFormModal's link-import
+// helper. Each parser returns a wire-shape outbound record (the same
+// shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
+// null when the input doesn't match.
+//
+// Scope: address + port + auth + remark, plus the network/security
+// fields the common vmess:// / vless:// links carry as query params.
+// Advanced transport fields (xmux, padding obfs, hysteria udphop,
+// reality short IDs, etc.) are not parsed — the user finishes them
+// in the form after import. This is intentional: a focused parser
+// keeps the surface small; the legacy Outbound.fromLink was ~250
+// lines of dense edge-case handling we don't need to replicate
+// verbatim for the common phone-to-panel workflow.
+
+type Raw = Record<string, unknown>;
+
+function buildStream(network: string, security: string): Raw {
+  const stream: Raw = { network, security };
+  switch (network) {
+    case 'tcp':
+      stream.tcpSettings = { header: { type: 'none' } };
+      break;
+    case 'kcp':
+      stream.kcpSettings = {
+        mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
+        cwndMultiplier: 1, maxSendingWindow: 2097152,
+      };
+      break;
+    case 'ws':
+      stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
+      break;
+    case 'grpc':
+      stream.grpcSettings = { serviceName: '', authority: '', multiMode: false };
+      break;
+    case 'httpupgrade':
+      stream.httpupgradeSettings = { path: '/', host: '', headers: {} };
+      break;
+    case 'xhttp':
+      stream.xhttpSettings = {
+        path: '/', host: '', mode: 'auto', headers: {},
+        xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
+      };
+      break;
+    default:
+      stream.tcpSettings = { header: { type: 'none' } };
+  }
+  if (security === 'tls') {
+    stream.tlsSettings = {
+      serverName: '', alpn: [], fingerprint: '',
+      echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
+    };
+  } else if (security === 'reality') {
+    stream.realitySettings = {
+      publicKey: '', fingerprint: 'chrome', serverName: '',
+      shortId: '', spiderX: '', mldsa65Verify: '',
+    };
+  }
+  return stream;
+}
+
+function applyTransportParams(stream: Raw, params: URLSearchParams): void {
+  const network = stream.network as string;
+  const host = params.get('host') ?? '';
+  const path = params.get('path') ?? '/';
+  switch (network) {
+    case 'ws':
+      (stream.wsSettings as Raw).host = host;
+      (stream.wsSettings as Raw).path = path;
+      break;
+    case 'grpc': {
+      const grpc = stream.grpcSettings as Raw;
+      const serviceName = params.get('serviceName') ?? params.get('path') ?? '';
+      grpc.serviceName = serviceName;
+      grpc.authority = params.get('authority') ?? '';
+      grpc.multiMode = params.get('mode') === 'multi';
+      break;
+    }
+    case 'httpupgrade':
+      (stream.httpupgradeSettings as Raw).host = host;
+      (stream.httpupgradeSettings as Raw).path = path;
+      break;
+    case 'xhttp':
+      (stream.xhttpSettings as Raw).host = host;
+      (stream.xhttpSettings as Raw).path = path;
+      if (params.get('mode')) (stream.xhttpSettings as Raw).mode = params.get('mode');
+      break;
+    case 'tcp':
+      // vless/trojan TCP HTTP camouflage rides on header=http+host+path
+      if (params.get('headerType') === 'http' || params.get('type') === 'http') {
+        (stream.tcpSettings as Raw).header = {
+          type: 'http',
+          request: {
+            version: '1.1',
+            method: 'GET',
+            path: path.split(',').filter(Boolean),
+            headers: host ? { Host: host.split(',').filter(Boolean) } : {},
+          },
+        };
+      }
+      break;
+  }
+}
+
+function applySecurityParams(stream: Raw, params: URLSearchParams): void {
+  if (stream.security === 'tls') {
+    const tls = stream.tlsSettings as Raw;
+    tls.serverName = params.get('sni') ?? '';
+    tls.fingerprint = params.get('fp') ?? '';
+    const alpn = params.get('alpn');
+    if (alpn) tls.alpn = alpn.split(',');
+  } else if (stream.security === 'reality') {
+    const reality = stream.realitySettings as Raw;
+    reality.serverName = params.get('sni') ?? '';
+    reality.fingerprint = params.get('fp') ?? 'chrome';
+    reality.publicKey = params.get('pbk') ?? '';
+    reality.shortId = params.get('sid') ?? '';
+    reality.spiderX = params.get('spx') ?? '';
+  }
+}
+
+function decodeRemark(url: URL): string {
+  try {
+    return decodeURIComponent(url.hash.replace(/^#/, ''));
+  } catch {
+    return url.hash.replace(/^#/, '');
+  }
+}
+
+export function parseVmessLink(link: string): Raw | null {
+  if (!link.startsWith('vmess://')) return null;
+  try {
+    const decoded = Base64.decode(link.slice('vmess://'.length));
+    const json = JSON.parse(decoded) as Record<string, unknown>;
+    const network = (json.net as string) || 'tcp';
+    const security = json.tls === 'tls' ? 'tls' : 'none';
+    const stream = buildStream(network, security);
+    // Map the vmess JSON's net-specific keys onto the stream branch.
+    if (network === 'tcp' && json.type === 'http') {
+      (stream.tcpSettings as Raw).header = {
+        type: 'http',
+        request: {
+          version: '1.1', method: 'GET',
+          path: (json.path as string ?? '/').split(',').filter(Boolean),
+          headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
+        },
+      };
+    } else if (network === 'ws') {
+      (stream.wsSettings as Raw).host = json.host ?? '';
+      (stream.wsSettings as Raw).path = json.path ?? '/';
+    } else if (network === 'grpc') {
+      (stream.grpcSettings as Raw).serviceName = json.path ?? '';
+      (stream.grpcSettings as Raw).authority = json.authority ?? '';
+      (stream.grpcSettings as Raw).multiMode = json.type === 'multi';
+    } else if (network === 'httpupgrade') {
+      (stream.httpupgradeSettings as Raw).host = json.host ?? '';
+      (stream.httpupgradeSettings as Raw).path = json.path ?? '/';
+    } else if (network === 'xhttp') {
+      (stream.xhttpSettings as Raw).host = json.host ?? '';
+      (stream.xhttpSettings as Raw).path = json.path ?? '/';
+      if (json.mode) (stream.xhttpSettings as Raw).mode = json.mode;
+    }
+    if (security === 'tls') {
+      const tls = stream.tlsSettings as Raw;
+      tls.serverName = json.sni ?? '';
+      tls.fingerprint = json.fp ?? '';
+      if (json.alpn) tls.alpn = (json.alpn as string).split(',');
+    }
+
+    const port = Number(json.port) || 443;
+    return {
+      protocol: 'vmess',
+      tag: typeof json.ps === 'string' ? json.ps : '',
+      settings: {
+        vnext: [{
+          address: json.add ?? '',
+          port,
+          users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
+        }],
+      },
+      streamSettings: stream,
+    };
+  } catch {
+    return null;
+  }
+}
+
+function parseUrlLink(link: string, expectedProto: string): URL | null {
+  try {
+    const url = new URL(link);
+    if (url.protocol.replace(/:$/, '') !== expectedProto) return null;
+    return url;
+  } catch {
+    return null;
+  }
+}
+
+export function parseVlessLink(link: string): Raw | null {
+  const url = parseUrlLink(link, 'vless');
+  if (!url) return null;
+  const id = url.username;
+  const address = url.hostname;
+  const port = Number(url.port) || 443;
+  const params = url.searchParams;
+  const network = params.get('type') ?? 'tcp';
+  const security = (params.get('security') ?? 'none') as string;
+  const stream = buildStream(network, security);
+  applyTransportParams(stream, params);
+  applySecurityParams(stream, params);
+  return {
+    protocol: 'vless',
+    tag: decodeRemark(url),
+    settings: {
+      address,
+      port,
+      id,
+      flow: params.get('flow') ?? '',
+      encryption: params.get('encryption') ?? 'none',
+    },
+    streamSettings: stream,
+  };
+}
+
+export function parseTrojanLink(link: string): Raw | null {
+  const url = parseUrlLink(link, 'trojan');
+  if (!url) return null;
+  const password = url.username;
+  const address = url.hostname;
+  const port = Number(url.port) || 443;
+  const params = url.searchParams;
+  const network = params.get('type') ?? 'tcp';
+  const security = (params.get('security') ?? 'tls') as string;
+  const stream = buildStream(network, security);
+  applyTransportParams(stream, params);
+  applySecurityParams(stream, params);
+  return {
+    protocol: 'trojan',
+    tag: decodeRemark(url),
+    settings: {
+      servers: [{ address, port, password }],
+    },
+    streamSettings: stream,
+  };
+}
+
+export function parseShadowsocksLink(link: string): Raw | null {
+  if (!link.startsWith('ss://')) return null;
+  // Two link shapes coexist:
+  //   modern:  ss://base64(method:password)@host:port#remark
+  //   legacy:  ss://base64(method:password@host:port)#remark
+  // Try modern first; fall back to legacy decode of the whole userinfo+host.
+  let userInfo: string;
+  let host: string;
+  let port: number;
+  let remark = '';
+  const hashIndex = link.indexOf('#');
+  const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
+  if (hashIndex >= 0) {
+    try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
+  }
+  const atIndex = linkNoHash.indexOf('@');
+  if (atIndex >= 0) {
+    try { userInfo = Base64.decode(linkNoHash.slice('ss://'.length, atIndex)); }
+    catch { userInfo = linkNoHash.slice('ss://'.length, atIndex); }
+    const hostPort = linkNoHash.slice(atIndex + 1);
+    const colon = hostPort.lastIndexOf(':');
+    if (colon < 0) return null;
+    host = hostPort.slice(0, colon);
+    port = Number(hostPort.slice(colon + 1)) || 443;
+  } else {
+    let decoded: string;
+    try { decoded = Base64.decode(linkNoHash.slice('ss://'.length)); }
+    catch { return null; }
+    const at = decoded.indexOf('@');
+    if (at < 0) return null;
+    userInfo = decoded.slice(0, at);
+    const hostPort = decoded.slice(at + 1);
+    const colon = hostPort.lastIndexOf(':');
+    if (colon < 0) return null;
+    host = hostPort.slice(0, colon);
+    port = Number(hostPort.slice(colon + 1)) || 443;
+  }
+  const sep = userInfo.indexOf(':');
+  const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
+  const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
+  return {
+    protocol: 'shadowsocks',
+    tag: remark,
+    settings: {
+      servers: [{ address: host, port, password, method }],
+    },
+  };
+}
+
+export function parseHysteria2Link(link: string): Raw | null {
+  const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2');
+  if (!url) return null;
+  // hysteria2's auth rides as the URL userinfo. The streamSettings
+  // network branch is the dedicated 'hysteria' transport — the modal's
+  // newStreamSlice('hysteria') initializer fills in receive-window
+  // defaults; we override the user-set fields here.
+  const auth = url.username;
+  const address = url.hostname;
+  const port = Number(url.port) || 443;
+  const params = url.searchParams;
+  const stream: Raw = {
+    network: 'hysteria',
+    security: 'tls',
+    hysteriaSettings: {
+      version: 2, auth, congestion: '', up: '0', down: '0',
+      initStreamReceiveWindow: 8388608, maxStreamReceiveWindow: 8388608,
+      initConnectionReceiveWindow: 20971520, maxConnectionReceiveWindow: 20971520,
+      maxIdleTimeout: 30, keepAlivePeriod: 2, disablePathMTUDiscovery: false,
+    },
+    tlsSettings: {
+      serverName: params.get('sni') ?? '',
+      alpn: ['h3'],
+      fingerprint: '',
+      echConfigList: '',
+      verifyPeerCertByName: '',
+      pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
+    },
+  };
+  return {
+    protocol: 'hysteria',
+    tag: decodeRemark(url),
+    settings: { address, port, version: 2 },
+    streamSettings: stream,
+  };
+}
+
+// Dispatcher — first non-null parser wins. Returns null when no parser
+// recognizes the link's protocol scheme.
+export function parseOutboundLink(link: string): Raw | null {
+  const trimmed = link.trim();
+  if (!trimmed) return null;
+  return (
+    parseVmessLink(trimmed)
+    ?? parseVlessLink(trimmed)
+    ?? parseTrojanLink(trimmed)
+    ?? parseShadowsocksLink(trimmed)
+    ?? parseHysteria2Link(trimmed)
+  );
+}

+ 32 - 0
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -23,6 +23,7 @@ import {
   formValuesToWirePayload,
   rawOutboundToFormValues,
 } from '@/lib/xray/outbound-form-adapter';
+import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
 import {
   OutboundFormBaseSchema,
   ShadowsocksOutboundFormSettingsSchema,
@@ -189,6 +190,30 @@ export default function OutboundFormModal({
   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
@@ -2081,6 +2106,13 @@ export default function OutboundFormModal({
                 label: 'JSON',
                 children: (
                   <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
+                    <Input.Search
+                      value={linkInput}
+                      placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
+                      enterButton="Import"
+                      onChange={(e) => setLinkInput(e.target.value)}
+                      onSearch={importLink}
+                    />
                     <JsonEditor
                       value={jsonText}
                       onChange={(next) => {

+ 159 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -0,0 +1,159 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  parseOutboundLink,
+  parseShadowsocksLink,
+  parseTrojanLink,
+  parseVlessLink,
+  parseVmessLink,
+  parseHysteria2Link,
+} from '@/lib/xray/outbound-link-parser';
+import { Base64 } from '@/utils';
+
+// Focused acceptance tests for the share-link parsers — one happy-path
+// case per protocol family, plus a few common edge cases. The parsers
+// produce wire-shape outbound rows; the modal hands them to
+// rawOutboundToFormValues to seed Form.useForm.
+
+describe('parseVmessLink', () => {
+  it('parses a vmess:// link with ws + tls', () => {
+    const json = {
+      v: '2', ps: 'imported-vmess', add: '1.2.3.4', port: 8443,
+      id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
+      net: 'ws', host: 'example.com', path: '/ws',
+      tls: 'tls', sni: 'example.com', fp: 'chrome', alpn: 'h2,http/1.1',
+    };
+    const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
+    const out = parseVmessLink(link);
+    expect(out).not.toBeNull();
+    expect(out?.protocol).toBe('vmess');
+    expect(out?.tag).toBe('imported-vmess');
+    const settings = out?.settings as { vnext: Array<{ address: string; port: number; users: Array<{ id: string; security: string }> }> };
+    expect(settings.vnext[0].address).toBe('1.2.3.4');
+    expect(settings.vnext[0].port).toBe(8443);
+    expect(settings.vnext[0].users[0].id).toBe('11111111-2222-4333-8444-555555555555');
+    const stream = out?.streamSettings as Record<string, unknown>;
+    expect(stream.network).toBe('ws');
+    expect(stream.security).toBe('tls');
+    expect((stream.wsSettings as Record<string, unknown>).path).toBe('/ws');
+    expect((stream.tlsSettings as Record<string, unknown>).serverName).toBe('example.com');
+    expect((stream.tlsSettings as Record<string, unknown>).alpn).toEqual(['h2', 'http/1.1']);
+  });
+
+  it('returns null for non-vmess links', () => {
+    expect(parseVmessLink('vless://x@y:1')).toBeNull();
+  });
+
+  it('returns null for malformed base64', () => {
+    expect(parseVmessLink('vmess://!!!not-base64!!!')).toBeNull();
+  });
+});
+
+describe('parseVlessLink', () => {
+  it('parses a vless:// link with reality', () => {
+    const link
+      = 'vless://[email protected]:443'
+      + '?type=tcp&security=reality&pbk=pubkey&sid=abcd&fp=chrome&sni=cloudflare.com&flow=xtls-rprx-vision'
+      + '#imported-vless';
+    const out = parseVlessLink(link);
+    expect(out?.protocol).toBe('vless');
+    expect(out?.tag).toBe('imported-vless');
+    const settings = out?.settings as { id: string; flow: string; address: string; port: number };
+    expect(settings.id).toBe('11111111-2222-4333-8444-555555555555');
+    expect(settings.address).toBe('srv.example');
+    expect(settings.port).toBe(443);
+    expect(settings.flow).toBe('xtls-rprx-vision');
+    const stream = out?.streamSettings as Record<string, unknown>;
+    expect(stream.security).toBe('reality');
+    const reality = stream.realitySettings as Record<string, unknown>;
+    expect(reality.publicKey).toBe('pubkey');
+    expect(reality.shortId).toBe('abcd');
+    expect(reality.serverName).toBe('cloudflare.com');
+  });
+});
+
+describe('parseTrojanLink', () => {
+  it('parses a trojan:// link with ws + tls', () => {
+    const link = 'trojan://[email protected]:8443?type=ws&security=tls&host=example.com&path=/tj&sni=example.com#imported-trojan';
+    const out = parseTrojanLink(link);
+    expect(out?.protocol).toBe('trojan');
+    const settings = out?.settings as { servers: Array<{ address: string; port: number; password: string }> };
+    expect(settings.servers[0].address).toBe('srv.example');
+    expect(settings.servers[0].port).toBe(8443);
+    expect(settings.servers[0].password).toBe('secret-pw');
+    const stream = out?.streamSettings as Record<string, unknown>;
+    expect(stream.network).toBe('ws');
+    expect((stream.wsSettings as Record<string, unknown>).path).toBe('/tj');
+  });
+});
+
+describe('parseShadowsocksLink', () => {
+  it('parses the modern userinfo@host:port form', () => {
+    // ss://base64(method:password)@host:port#remark
+    const userinfo = Base64.encode('2022-blake3-aes-128-gcm:supersecret');
+    const link = `ss://${userinfo}@1.2.3.4:8388#imported-ss`;
+    const out = parseShadowsocksLink(link);
+    expect(out?.protocol).toBe('shadowsocks');
+    expect(out?.tag).toBe('imported-ss');
+    const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
+    expect(settings.servers[0].address).toBe('1.2.3.4');
+    expect(settings.servers[0].port).toBe(8388);
+    expect(settings.servers[0].method).toBe('2022-blake3-aes-128-gcm');
+    expect(settings.servers[0].password).toBe('supersecret');
+  });
+
+  it('parses the legacy base64-of-whole form', () => {
+    // ss://base64(method:password@host:port)#remark
+    const inner = Base64.encode('aes-256-gcm:[email protected]:1080');
+    const link = `ss://${inner}#imported-legacy`;
+    const out = parseShadowsocksLink(link);
+    const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
+    expect(settings.servers[0].address).toBe('10.0.0.1');
+    expect(settings.servers[0].port).toBe(1080);
+    expect(settings.servers[0].method).toBe('aes-256-gcm');
+    expect(settings.servers[0].password).toBe('legacypw');
+  });
+});
+
+describe('parseHysteria2Link', () => {
+  it('parses a hysteria2:// link with sni', () => {
+    const link = 'hysteria2://[email protected]:443?sni=example.com#imported-hy2';
+    const out = parseHysteria2Link(link);
+    expect(out?.protocol).toBe('hysteria');
+    expect(out?.tag).toBe('imported-hy2');
+    const settings = out?.settings as { address: string; port: number; version: number };
+    expect(settings.address).toBe('srv.example');
+    expect(settings.port).toBe(443);
+    expect(settings.version).toBe(2);
+    const stream = out?.streamSettings as Record<string, unknown>;
+    const hys = stream.hysteriaSettings as Record<string, unknown>;
+    expect(hys.auth).toBe('auth-secret');
+    expect((stream.tlsSettings as Record<string, unknown>).serverName).toBe('example.com');
+  });
+
+  it('also accepts hy2:// alias', () => {
+    const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com');
+    expect(out?.protocol).toBe('hysteria');
+  });
+});
+
+describe('parseOutboundLink dispatcher', () => {
+  it('dispatches vmess via base64 JSON', () => {
+    const json = { v: '2', ps: 'x', add: '1.1.1.1', port: 443, id: '11111111-2222-4333-8444-555555555555', net: 'tcp', tls: 'none' };
+    const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
+    expect(parseOutboundLink(link)?.protocol).toBe('vmess');
+  });
+
+  it('dispatches vless via URL', () => {
+    expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe('vless');
+  });
+
+  it('returns null for an unknown scheme', () => {
+    expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull();
+  });
+
+  it('returns null for empty input', () => {
+    expect(parseOutboundLink('')).toBeNull();
+    expect(parseOutboundLink('   ')).toBeNull();
+  });
+});