ソースを参照

feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement (#6488)

* feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement

* fix(sub): address PR review findings on routing parity, agent regex, and i18n
Pejman Yousefi 8 時間 前
コミット
cba8f0672f

+ 61 - 65
frontend/src/pages/settings/HappSettingsContent.tsx

@@ -51,55 +51,19 @@ export default function HappSettingsContent({
   };
 
   const handleBuildDeeplink = () => {
-    interface FieldRule {
-      type: string;
-      outboundTag: string;
-      domain?: string[];
-      ip?: string[];
-      network?: string;
-    }
-    const rules: FieldRule[] = [];
-
-    const bDom = parseList(blockDomains);
-    const bIp = parseList(blockIPs);
-    if (bDom.length > 0 || bIp.length > 0) {
-      rules.push({
-        type: 'field',
-        outboundTag: 'block',
-        ...(bDom.length > 0 ? { domain: bDom } : {}),
-        ...(bIp.length > 0 ? { ip: bIp } : {}),
-      });
-    }
-
-    const dDom = parseList(directDomains);
-    const dIp = parseList(directIPs);
-    if (dDom.length > 0 || dIp.length > 0) {
-      rules.push({
-        type: 'field',
-        outboundTag: 'direct',
-        ...(dDom.length > 0 ? { domain: dDom } : {}),
-        ...(dIp.length > 0 ? { ip: dIp } : {}),
-      });
-    }
-
-    const pDom = parseList(proxyDomains);
-    const pIp = parseList(proxyIPs);
-    if (pDom.length > 0 || pIp.length > 0) {
-      rules.push({
-        type: 'field',
-        outboundTag: 'proxy',
-        ...(pDom.length > 0 ? { domain: pDom } : {}),
-        ...(pIp.length > 0 ? { ip: pIp } : {}),
-      });
-    }
-
-    rules.push({
-      type: 'field',
-      outboundTag: 'proxy',
-      network: 'tcp,udp',
-    });
-
-    const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify({ rules }));
+    const profile = {
+      Name: 'Custom Rules',
+      GlobalProxy: 'true',
+      DirectSites: parseList(directDomains),
+      DirectIp: parseList(directIPs),
+      ProxySites: parseList(proxyDomains),
+      ProxyIp: parseList(proxyIPs),
+      BlockSites: parseList(blockDomains),
+      BlockIp: parseList(blockIPs),
+      DomainStrategy: 'IPIfNonMatch',
+    };
+
+    const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile));
     updateSetting({ subRoutingRules: deeplink });
     setIsModalOpen(false);
     message.success(t('pages.settings.subHappDeeplinkGenerated'));
@@ -107,6 +71,17 @@ export default function HappSettingsContent({
 
   return (
     <>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.settings.subHappAutoDetect')}
+        description={t('pages.settings.subHappAutoDetectDesc')}
+      >
+        <Switch
+          checked={allSetting.subHappAutoDetect}
+          onChange={(v) => updateSetting({ subHappAutoDetect: v })}
+        />
+      </SettingListItem>
+
       <Tabs
         type="card"
         size="small"
@@ -454,11 +429,43 @@ export default function HappSettingsContent({
                   title={t('pages.settings.subHappColorProfile')}
                   description={t('pages.settings.subHappColorProfileDesc')}
                 >
-                  <Input
-                    value={allSetting.subHappColorProfile}
-                    placeholder="default, violet, turquoise, cyberpunk, or custom JSON"
-                    onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
-                  />
+                  <Space orientation="vertical" style={{ width: '100%' }}>
+                    <Input
+                      value={allSetting.subHappColorProfile}
+                      placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
+                      onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
+                    />
+                    <Space wrap size="small">
+                      <Button
+                        size="small"
+                        onClick={() => updateSetting({ subHappColorProfile: 'resetcolors' })}
+                      >
+                        {t('reset')}
+                      </Button>
+                      <Button
+                        size="small"
+                        onClick={() =>
+                          updateSetting({
+                            subHappColorProfile:
+                              '{"serverRowBackgroundColor":"#21003D67","cardBackgroundColor":"#120023B3"}',
+                          })
+                        }
+                      >
+                        Violet
+                      </Button>
+                      <Button
+                        size="small"
+                        onClick={() =>
+                          updateSetting({
+                            subHappColorProfile:
+                              '{"serverRowBackgroundColor":"#002B3667","cardBackgroundColor":"#001F27B3"}',
+                          })
+                        }
+                      >
+                        Turquoise
+                      </Button>
+                    </Space>
+                  </Space>
                 </SettingListItem>
               </>
             ),
@@ -472,17 +479,6 @@ export default function HappSettingsContent({
             ),
             children: (
               <>
-                <SettingListItem
-                  paddings="small"
-                  title={t('pages.settings.subHappAutoDetect')}
-                  description={t('pages.settings.subHappAutoDetectDesc')}
-                >
-                  <Switch
-                    checked={allSetting.subHappAutoDetect}
-                    onChange={(v) => updateSetting({ subHappAutoDetect: v })}
-                  />
-                </SettingListItem>
-
                 <SettingListItem
                   paddings="small"
                   title={t('pages.settings.subHappProviderId')}

+ 36 - 60
frontend/src/pages/settings/happPresets.ts

@@ -25,24 +25,15 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            rules: [
-              {
-                type: 'field',
-                outboundTag: 'direct',
-                domain: ['domain:ir', 'regexp:.*\\.ir$'],
-                ip: ['geoip:ir', 'geoip:private'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'block',
-                domain: ['geosite:category-ads-all'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'proxy',
-                network: 'tcp,udp',
-              },
-            ],
+            Name: 'Iran Bypass',
+            GlobalProxy: 'true',
+            DirectSites: ['domain:ir', 'regexp:.*\\.ir$'],
+            DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
+            BlockSites: ['geosite:category-ads-all'],
+            BlockIp: [],
+            ProxySites: [],
+            ProxyIp: [],
+            DomainStrategy: 'IPIfNonMatch',
           }),
         )
       );
@@ -51,24 +42,15 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            rules: [
-              {
-                type: 'field',
-                outboundTag: 'direct',
-                domain: ['domain:cn', 'geosite:cn'],
-                ip: ['geoip:cn', 'geoip:private'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'block',
-                domain: ['geosite:category-ads-all'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'proxy',
-                network: 'tcp,udp',
-              },
-            ],
+            Name: 'China Direct',
+            GlobalProxy: 'true',
+            DirectSites: ['geosite:cn', 'geosite:geolocation-cn'],
+            DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
+            BlockSites: ['geosite:category-ads-all'],
+            BlockIp: [],
+            ProxySites: [],
+            ProxyIp: [],
+            DomainStrategy: 'IPIfNonMatch',
           }),
         )
       );
@@ -77,23 +59,15 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            rules: [
-              {
-                type: 'field',
-                outboundTag: 'block',
-                domain: ['geosite:category-ads-all'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'direct',
-                ip: ['geoip:private'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'proxy',
-                network: 'tcp,udp',
-              },
-            ],
+            Name: 'AdBlock',
+            GlobalProxy: 'true',
+            DirectSites: [],
+            DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
+            BlockSites: ['geosite:category-ads-all'],
+            BlockIp: [],
+            ProxySites: [],
+            ProxyIp: [],
+            DomainStrategy: 'IPIfNonMatch',
           }),
         )
       );
@@ -102,13 +76,15 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            rules: [
-              {
-                type: 'field',
-                outboundTag: 'proxy',
-                network: 'tcp,udp',
-              },
-            ],
+            Name: 'Global Proxy',
+            GlobalProxy: 'true',
+            DirectSites: [],
+            DirectIp: [],
+            BlockSites: [],
+            BlockIp: [],
+            ProxySites: [],
+            ProxyIp: [],
+            DomainStrategy: 'AsIs',
           }),
         )
       );

+ 12 - 22
frontend/src/test/happ-presets.test.ts

@@ -22,19 +22,11 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    expect(parsed).toHaveProperty('rules');
-    expect(Array.isArray(parsed.rules)).toBe(true);
-
-    const directRule = parsed.rules.find(
-      (r: { outboundTag: string }) => r.outboundTag === 'direct',
-    );
-    expect(directRule).toBeDefined();
-    expect(directRule.domain).toContain('domain:ir');
-    expect(directRule.ip).toContain('geoip:ir');
-
-    const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
-    expect(blockRule).toBeDefined();
-    expect(blockRule.domain).toContain('geosite:category-ads-all');
+    expect(parsed.Name).toBe('Iran Bypass');
+    expect(parsed.GlobalProxy).toBe('true');
+    expect(parsed.DirectSites).toContain('domain:ir');
+    expect(parsed.DirectIp).toContain('geoip:ir');
+    expect(parsed.BlockSites).toContain('geosite:category-ads-all');
   });
 
   it('generates valid base64 payload for china-direct preset', () => {
@@ -45,11 +37,9 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    const directRule = parsed.rules.find(
-      (r: { outboundTag: string }) => r.outboundTag === 'direct',
-    );
-    expect(directRule.domain).toContain('domain:cn');
-    expect(directRule.ip).toContain('geoip:cn');
+    expect(parsed.Name).toBe('China Direct');
+    expect(parsed.DirectSites).toContain('geosite:cn');
+    expect(parsed.DirectIp).toContain('geoip:cn');
   });
 
   it('generates valid base64 payload for adblock preset', () => {
@@ -58,8 +48,8 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
-    expect(blockRule.domain).toContain('geosite:category-ads-all');
+    expect(parsed.Name).toBe('AdBlock');
+    expect(parsed.BlockSites).toContain('geosite:category-ads-all');
   });
 
   it('generates valid base64 payload for global preset', () => {
@@ -68,8 +58,8 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    expect(parsed.rules[0].outboundTag).toBe('proxy');
-    expect(parsed.rules[0].network).toBe('tcp,udp');
+    expect(parsed.Name).toBe('Global Proxy');
+    expect(parsed.DomainStrategy).toBe('AsIs');
   });
 
   it('encodes unicode properly via toBase64Utf8', () => {

+ 21 - 0
frontend/src/test/happ-settings-tun-mode.test.tsx

@@ -69,4 +69,25 @@ describe('Happ TUN Mode select', () => {
     const select = selectFor('TUN Mode');
     expect(select.querySelector('.ant-select-content')?.textContent).toBe('Default');
   });
+
+  it('renders Auto-Detection master switch at the top and triggers update', () => {
+    const updateSetting = vi.fn();
+    const allSetting = new AllSetting();
+    allSetting.subHappAutoDetect = false;
+
+    renderWithProviders(
+      <HappSettingsContent
+        allSetting={allSetting}
+        updateSetting={updateSetting}
+        isMobile={false}
+        remoteSourceBadge={() => null}
+      />,
+    );
+
+    const switchBtn = document.querySelector('.ant-switch');
+    if (!switchBtn) throw new Error('switch not found');
+    fireEvent.click(switchBtn);
+
+    expect(updateSetting).toHaveBeenCalledWith({ subHappAutoDetect: true });
+  });
 });

+ 23 - 5
internal/sub/endpoint.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"encoding/base64"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -20,10 +21,11 @@ import (
 // because three behaviors branch on the raw string (keep-base, obj["tls"]
 // rewrite, none-strip).
 type ShareEndpoint struct {
-	Address  string
-	Port     int
-	Remark   string // extra remark slot fed to genRemark, not a rendered remark
-	ForceTls string
+	Address           string
+	Port              int
+	Remark            string // extra remark slot fed to genRemark, not a rendered remark
+	ServerDescription string // subtitle caption displayed in Happ client
+	ForceTls          string
 
 	// ep is the source externalProxy entry. nil for host/default endpoints.
 	ep map[string]any
@@ -38,6 +40,7 @@ func externalProxyToEndpoint(ep map[string]any) ShareEndpoint {
 		e.Port = int(p)
 	}
 	e.Remark, _ = ep["remark"].(string)
+	e.ServerDescription, _ = ep["serverDescription"].(string)
 	e.ForceTls, _ = ep["forceTls"].(string)
 	return e
 }
@@ -106,10 +109,14 @@ func (s *SubService) buildEndpointLinks(
 		applyEndpointHostPath(e, nextParams)
 		applyEndpointFinalMask(e, nextParams)
 		applyEndpointAllowInsecure(e, nextParams, securityToApply)
+		remark := makeRemark(e)
+		if e.ServerDescription != "" {
+			remark = appendHappServerDescription(remark, e.ServerDescription)
+		}
 		links = append(links, buildLinkWithParamsAndSecurity(
 			makeLink(e),
 			nextParams,
-			makeRemark(e),
+			remark,
 			securityToApply,
 			e.ForceTls == "none",
 		))
@@ -117,6 +124,14 @@ func (s *SubService) buildEndpointLinks(
 	return strings.Join(links, "\n")
 }
 
+func appendHappServerDescription(remark, desc string) string {
+	if desc == "" {
+		return remark
+	}
+	encoded := base64.StdEncoding.EncodeToString([]byte(desc))
+	return remark + "?serverDescription=" + encoded
+}
+
 // buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
 func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
 	var links strings.Builder
@@ -132,6 +147,9 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
 		if e.ForceTls != "same" {
 			newObj["tls"] = e.ForceTls
 		}
+		if e.ServerDescription != "" {
+			newObj["serverDescription"] = e.ServerDescription
+		}
 		applyEndpointTLSObj(e, newObj, securityToApply)
 		applyEndpointHostPathObj(e, newObj)
 		applyEndpointFinalMaskObj(e, newObj)

+ 4 - 4
internal/sub/endpoint_test.go

@@ -116,8 +116,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
 	}
 }
 
-// happ.su documents serverDescription as a "#title?serverDescription=<base64>"
-// link parameter, never a key of the VMess object, so nothing may leak into it.
+// happ.su documents serverDescription for VMess as a JSON object key
+// {"add":"...","ps":"...","serverDescription":"Happ the best"}.
 func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
 	s := &SubService{}
 	in := &model.Inbound{Remark: "ib"}
@@ -137,8 +137,8 @@ func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
 	if obj["add"] != "a.example.com" {
 		t.Fatalf("host endpoint not applied: add = %v", obj["add"])
 	}
-	if value, ok := obj["serverDescription"]; ok {
-		t.Fatalf("VMess object carries serverDescription = %v; it is not a VMess object key", value)
+	if obj["serverDescription"] != "Berlin premium" {
+		t.Fatalf("VMess object missing serverDescription: got %v, want Berlin premium", obj["serverDescription"])
 	}
 }
 

+ 11 - 8
internal/sub/happ.go

@@ -41,21 +41,25 @@ func IsHappClient(userAgent string) bool {
 	return happUserAgentRegex.MatchString(userAgent)
 }
 
+func sanitizeHeaderValue(v string) string {
+	return strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(v), "\r", ""), "\n", "")
+}
+
 // ApplyHappHeaders sets standard and advanced Happ subscription headers.
 func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
 	if c == nil || c.Writer == nil || !cfg.AutoDetect || !isHapp {
 		return
 	}
 	if cfg.ProviderId != "" {
-		c.Writer.Header().Set("ProviderID", cfg.ProviderId)
+		c.Writer.Header().Set("ProviderID", strings.TrimSpace(cfg.ProviderId))
 	}
 	if cfg.NewUrl != "" {
-		c.Writer.Header().Set("New-Url", cfg.NewUrl)
+		c.Writer.Header().Set("New-Url", strings.TrimSpace(cfg.NewUrl))
 	}
 	if cfg.FallbackUrl != "" {
-		c.Writer.Header().Set("Fallback-Url", cfg.FallbackUrl)
+		c.Writer.Header().Set("Fallback-Url", strings.TrimSpace(cfg.FallbackUrl))
 	}
-	if text := strings.TrimSpace(cfg.SubInfoText); text != "" {
+	if text := sanitizeHeaderValue(cfg.SubInfoText); text != "" {
 		color := strings.TrimSpace(cfg.SubInfoColor)
 		switch strings.ToLower(color) {
 		case "primary", "info":
@@ -69,10 +73,10 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
 		}
 		c.Writer.Header().Set("Sub-Info-Color", color)
 		c.Writer.Header().Set("Sub-Info-Text", text)
-		if btnText := strings.TrimSpace(cfg.SubInfoButtonText); btnText != "" {
+		if btnText := sanitizeHeaderValue(cfg.SubInfoButtonText); btnText != "" {
 			c.Writer.Header().Set("Sub-Info-Button-Text", btnText)
 		}
-		if btnLink := strings.TrimSpace(cfg.SubInfoButtonLink); btnLink != "" {
+		if btnLink := sanitizeHeaderValue(cfg.SubInfoButtonLink); btnLink != "" {
 			c.Writer.Header().Set("Sub-Info-Button-Link", btnLink)
 		}
 	}
@@ -103,8 +107,7 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
 	if cfg.ExcludeApns {
 		c.Writer.Header().Set("Exclude-Apns-Enable", "true")
 	}
-	if profile := strings.TrimSpace(cfg.ColorProfile); profile != "" {
-		profile = strings.ReplaceAll(strings.ReplaceAll(profile, "\r", ""), "\n", "")
+	if profile := sanitizeHeaderValue(cfg.ColorProfile); profile != "" {
 		c.Writer.Header().Set("Color-Profile", profile)
 	}
 	if ping := strings.TrimSpace(cfg.PingType); ping != "" {

+ 124 - 1
internal/sub/happ_test.go

@@ -1,8 +1,10 @@
 package sub
 
 import (
+	"encoding/base64"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 
 	"github.com/gin-gonic/gin"
@@ -53,12 +55,15 @@ func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
 	ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
 	ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone; iOS 17.5)")
 
-	controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "", false)
+	controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "happ://routing/onadd/existing-rules", false)
 
 	h := recorder.Header()
 	if h.Get("Routing-Enable") != "0" {
 		t.Fatalf("Routing-Enable = %q, want 0 for Happ with disabled routing", h.Get("Routing-Enable"))
 	}
+	if h.Get("Routing") != "happ://routing/onadd/existing-rules" {
+		t.Fatalf("Routing = %q, want happ://routing/onadd/existing-rules for Happ with configured routing", h.Get("Routing"))
+	}
 	if h.Get("Hide-Settings") != "0" {
 		t.Fatalf("Hide-Settings = %q, want 0 for Happ with disabled hideSettings", h.Get("Hide-Settings"))
 	}
@@ -109,6 +114,28 @@ func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
 	}
 }
 
+func TestApplyCommonHeaders_HappRoutingOffDeeplink(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	cfg := HappConfig{AutoDetect: true}
+	controller := &SUBController{happConfig: cfg}
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
+	ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone)")
+
+	// When rules is happ://routing/off, Routing-Enable is 0 and Routing is happ://routing/off
+	controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "happ://routing/off", false)
+
+	h := recorder.Header()
+	if h.Get("Routing-Enable") != "0" {
+		t.Fatalf("Routing-Enable = %q, want 0 when rules is happ://routing/off", h.Get("Routing-Enable"))
+	}
+	if h.Get("Routing") != "happ://routing/off" {
+		t.Fatalf("Routing = %q, want happ://routing/off", h.Get("Routing"))
+	}
+}
+
 func TestApplyHappHeaders_Gating(t *testing.T) {
 	gin.SetMode(gin.TestMode)
 
@@ -165,6 +192,9 @@ func TestApplyHappHeaders_Gating(t *testing.T) {
 		if got := recorder.Header().Get("Hide-Settings"); got != "" {
 			t.Fatalf("Hide-Settings emitted when AutoDetect is false: %q", got)
 		}
+		if got := recorder.Header().Get("Routing"); got != "" {
+			t.Fatalf("Routing emitted when AutoDetect is false: %q", got)
+		}
 	})
 }
 
@@ -225,3 +255,96 @@ func TestApplyHappHeaders_Aliases(t *testing.T) {
 		})
 	}
 }
+
+func TestAppendHappServerDescription(t *testing.T) {
+	desc := "VIP Server"
+	encoded := base64.StdEncoding.EncodeToString([]byte(desc))
+
+	got := appendHappServerDescription("My Node", desc)
+	want := "My Node?serverDescription=" + encoded
+	if got != want {
+		t.Fatalf("appendHappServerDescription = %q, want %q", got, want)
+	}
+
+	if gotEmpty := appendHappServerDescription("My Node", ""); gotEmpty != "My Node" {
+		t.Fatalf("appendHappServerDescription with empty desc = %q, want My Node", gotEmpty)
+	}
+}
+
+func TestAppendQueryAndFragment_PreservesServerDescription(t *testing.T) {
+	desc := "Fast Server"
+	encoded := base64.StdEncoding.EncodeToString([]byte(desc))
+
+	t.Run("preserves serverDescription with encoded title", func(t *testing.T) {
+		fragment := "Server 01?serverDescription=" + encoded
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		want := "vless://user@host:443#Server%2001?serverDescription=" + encoded
+		if link != want {
+			t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
+		}
+	})
+
+	t.Run("properly escapes remark containing literal question mark without serverDescription", func(t *testing.T) {
+		fragment := "Fast? Server"
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		want := "vless://user@host:443#Fast%3F%20Server"
+		if link != want {
+			t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
+		}
+	})
+
+	t.Run("properly escapes remark containing literal question mark with serverDescription", func(t *testing.T) {
+		fragment := "Fast? Server?serverDescription=" + encoded
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		want := "vless://user@host:443#Fast%3F%20Server?serverDescription=" + encoded
+		if link != want {
+			t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
+		}
+	})
+
+	t.Run("escapes fragment when serverDescription tail contains invalid base64", func(t *testing.T) {
+		fragment := "Node 1?serverDescription=not-base64!!!"
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		want := "vless://user@host:443#Node%201%3FserverDescription%3Dnot-base64%21%21%21"
+		if link != want {
+			t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
+		}
+	})
+
+	t.Run("escapes fragment when serverDescription tail contains newline injection", func(t *testing.T) {
+		fragment := "Node 1?serverDescription=" + encoded + "\nevil://inject"
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		if strings.Contains(link, "\n") {
+			t.Fatalf("appendQueryAndFragment emitted raw newline: %q", link)
+		}
+	})
+}
+
+func TestIsHappClient(t *testing.T) {
+	matching := []string{
+		"Happ/1.2.0 (iPhone; iOS 17.5)",
+		"happ/2.0",
+		"happ",
+		"HAPP/1.0",
+		"Mozilla/5.0 Happ/1.0",
+	}
+	for _, ua := range matching {
+		if !IsHappClient(ua) {
+			t.Errorf("IsHappClient(%q) = false, want true", ua)
+		}
+	}
+
+	nonMatching := []string{
+		"Happy/2.0",
+		"happier-client",
+		"Happening/1.0",
+		"unhappy",
+		"v2rayNG/1.8.5",
+		"",
+	}
+	for _, ua := range nonMatching {
+		if IsHappClient(ua) {
+			t.Errorf("IsHappClient(%q) = true, want false", ua)
+		}
+	}
+}

+ 3 - 0
internal/sub/host_sub.go

@@ -107,6 +107,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int)
 	if h.VlessRoute != "" {
 		ep["vlessRoute"] = h.VlessRoute
 	}
+	if h.ServerDescription != "" {
+		ep["serverDescription"] = h.ServerDescription
+	}
 	return ep
 }
 

+ 12 - 2
internal/sub/service.go

@@ -2271,8 +2271,18 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
 
 	if fragment != "" {
 		sb.WriteByte('#')
-		// Match the frontend's encodeURIComponent(remark): spaces become %20.
-		sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
+		if before, after, ok := strings.Cut(fragment, "?serverDescription="); ok {
+			if _, err := base64.StdEncoding.DecodeString(after); err == nil && len(after) > 0 && !strings.ContainsAny(after, " \r\n\t#&") {
+				sb.WriteString(strings.ReplaceAll(url.QueryEscape(before), "+", "%20"))
+				sb.WriteString("?serverDescription=")
+				sb.WriteString(after)
+			} else {
+				sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
+			}
+		} else {
+			// Match the frontend's encodeURIComponent(remark): spaces become %20.
+			sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
+		}
 	}
 	return sb.String()
 }