diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 85760b37d..bd31c23b6 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -9,8 +9,9 @@ import { Base64 } from '@/utils'; // fields the common vmess:// / vless:// links carry as query params. // XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes, // scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when -// present in either the JSON or URL params. xmux, reality shortIds, -// padding obfs key/header/placement, hysteria udphop are still left +// present in either the JSON or URL params. xmux and downloadSettings +// round-trip through the `extra` JSON blob. reality shortIds, padding +// obfs key/header/placement, hysteria udphop are still left // to the user to fill in after import — 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. @@ -33,6 +34,10 @@ const XHTTP_NUMBER_KEYS = [ const XHTTP_BOOL_KEYS = [ 'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader', ] as const; +// Nested objects the inbound link bundles into the `extra` JSON blob +// (and vmess JSON carries inline). The outbound form adapter expands +// xmux into the XMUX sub-form (enableXmux) on load. +const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const; function asBool(s: string | null): boolean | undefined { if (s === null) return undefined; @@ -88,6 +93,10 @@ function applyXhttpStringFromJson(xhttp: Raw, json: Record): vo for (const k of XHTTP_BOOL_KEYS) { if (typeof json[k] === 'boolean') xhttp[k] = json[k]; } + for (const k of XHTTP_OBJECT_KEYS) { + const v = json[k]; + if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v; + } } function buildStream(network: string, security: string): Raw { diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index baf92f294..28dad1de8 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -392,6 +392,23 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => { expect(xhttp.xPaddingBytes).toBe('900-9000'); }); + it('extracts the nested xmux object from the extra JSON blob', () => { + // The inbound link bundles xmux into `extra` as a nested object + // (sub/service.go). It must survive import so the outbound form's + // XMUX sub-form populates rather than silently dropping it (#5353). + const extra = encodeURIComponent(JSON.stringify({ + xmux: { maxConcurrency: '8-16', hMaxRequestTimes: '700-1000' }, + })); + const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' + + '&extra=' + extra + '#t'; + const parsed = parseVlessLink(link); + const xhttp = (parsed!.streamSettings as Record).xhttpSettings as Record; + const xmux = xhttp.xmux as Record; + expect(xmux).toBeDefined(); + expect(xmux.maxConcurrency).toBe('8-16'); + expect(xmux.hMaxRequestTimes).toBe('700-1000'); + }); + it('ignores malformed extra JSON without breaking the rest of the link', () => { const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' + '&extra=not-json&fp=chrome#t';