From 966bab0d716f1bcf88b07ed2041734889dd880dc Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Mon, 3 Aug 2026 21:22:00 +0300 Subject: [PATCH] fix(frontend): add the missing AmneziaWG config download on the sub page The subscription page already gave WireGuard links their own "Config" block (copy/download/QR of the actual .conf, via wireguardConfigFromLink reversing the wireguard:// query params) but had no equivalent for AmneziaWG's vpn:// links -- its isWireguardLink gate never matched them, and no reverse-parse helper existed for this page specifically. Every other surface (InboundInfoModal, ClientInfoModal, ClientQrModal) already had this parity; this was the one page that didn't. Fixed by adding amneziawgConfigFromLink (inbound-link.ts), simpler than its WireGuard counterpart since a vpn:// payload already *is* the plain .conf text -- just base64url-decode it, no query-param reconstruction needed -- and wiring it into SubPage.tsx alongside the existing WireGuard block, reusing the same pages.clients.amneziaWgConfig label the other three surfaces already use. --- frontend/src/lib/xray/inbound-link.ts | 28 ++++++++++++++++++++++++++ frontend/src/pages/sub/SubPage.tsx | 12 ++++++++++- frontend/src/test/inbound-link.test.ts | 24 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index f08dfc400..78698cc33 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -1064,6 +1064,34 @@ export function wireguardConfigFromLink(link: string, fallbackRemark = ''): stri return lines.join('\n'); } +// Reverse of toBase64Url above -- recovers a vpn:// link's plain .conf +// payload for display/copy/download/QR, the AmneziaWG counterpart of +// wireguardConfigFromLink. Simpler than that function: a vpn:// link's +// payload already *is* the .conf text (see genAmneziaWGLink's own doc +// comment), so there's nothing to reconstruct from query params -- just +// decode. Mirrors link-label.tsx's own private fromBase64Url (used there +// only to pull the remark/port back out for the tag label); duplicated +// rather than imported since both are tiny, self-contained, and each +// file already owns the matching encode or decode half of this pair. +function fromBase64Url(value: string): string { + const b64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} + +export function amneziawgConfigFromLink(link: string): string { + const trimmed = link.trim(); + if (!trimmed.startsWith('vpn://')) return ''; + try { + return fromBase64Url(trimmed.slice('vpn://'.length)); + } catch { + return ''; + } +} + export type { WireguardInboundPeer }; function isUnixSocketListen(listen: string): boolean { diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx index 338599960..0e1eed571 100644 --- a/frontend/src/pages/sub/SubPage.tsx +++ b/frontend/src/pages/sub/SubPage.tsx @@ -33,7 +33,7 @@ import { } from '@ant-design/icons'; import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils'; -import { isPostQuantumLink, wireguardConfigFromLink } from '@/lib/xray/inbound-link'; +import { amneziawgConfigFromLink, isPostQuantumLink, wireguardConfigFromLink } from '@/lib/xray/inbound-link'; import { LinkTags, parseLinkParts } from '@/lib/xray/link-label'; import ConfigBlock from '@/components/clients/ConfigBlock'; import { setMessageInstance } from '@/utils/messageBus'; @@ -451,6 +451,7 @@ export default function SubPage() { const qrLabel = parts?.remark || rowTitle; const canQr = !isPostQuantumLink(link); const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://'); + const isAmneziawgLink = link.startsWith('vpn://'); return (
@@ -506,6 +507,15 @@ export default function SubPage() { tagColor="cyan" /> )} + {isAmneziawgLink && ( + + )} ); })} diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index d74e799ec..507cf2d56 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { + amneziawgConfigFromLink, genAmneziaWGConfig, genAmneziaWGLink, genHysteriaLink, @@ -409,6 +410,29 @@ describe('genAmneziaWGLink vpn:// scheme', () => { it('returns an empty string when the peer index has no client', () => { expect(genAmneziaWGLink({ ...input, peerIndex: 5 })).toBe(''); }); + + // The subscription page's own reverse of the above: recovers a vpn:// + // link's .conf text for the same copy/download/QR "Config" block + // WireGuard already gets there (wireguardConfigFromLink's AmneziaWG + // counterpart) -- found missing from that page in production (no + // download-config affordance for AmneziaWG links, unlike WireGuard's), + // even though every other surface in the panel (InboundInfoModal, + // ClientInfoModal, ClientQrModal) already had parity. + it('amneziawgConfigFromLink round-trips genAmneziaWGLink byte-identical to genAmneziaWGConfig', () => { + const link = genAmneziaWGLink(input); + expect(amneziawgConfigFromLink(link)).toBe(genAmneziaWGConfig(input)); + }); +}); + +describe('amneziawgConfigFromLink edge cases', () => { + it('returns an empty string for a non-vpn:// link', () => { + expect(amneziawgConfigFromLink('wireguard://abc')).toBe(''); + expect(amneziawgConfigFromLink('')).toBe(''); + }); + + it('returns an empty string for an unparseable vpn:// payload', () => { + expect(amneziawgConfigFromLink('vpn://not-valid-base64url!!!')).toBe(''); + }); }); describe('resolveAddr precedence', () => {