Преглед изворни кода

feat(sub): add dynamic remark variables with Jalali date, transport, and status tokens (#5430)

* feat(sub): implement dynamic single-bracket remark variables with timezone-aware inline Jalali conversion

* Update .gitignore

* Update .gitignore

* merge: bring in origin/main commits to resolve conflict base

* fix(sub): address review issues in dynamic remark variables

- Add TIME_LEFT to unlimitedDropTokens so segments containing only
  {TIME_LEFT} are dropped for unlimited clients (same as DAYS_LEFT)
- Remove dead uiSingleBraceRe variable (translateUISingleBrackets uses
  a character scanner, not this regex)
- Change expireDateLabel to use time.Local instead of UTC, consistent
  with jalaliExpireDateLabel

Co-authored-by: Sanaei <[email protected]>

* fix

* fix

---------

Co-authored-by: MHSanaei <[email protected]>
wahh3b-lgtm пре 12 часа
родитељ
комит
605e90dbf0

+ 2 - 1
.gitignore

@@ -43,4 +43,5 @@ system_metrics.gob
 docker-compose.override.yml
 
 # Ignore .env (Environment Variables) file
-.env
+.env
+

+ 10 - 0
frontend/public/openapi.json

@@ -222,6 +222,10 @@
             "description": "Encrypt subscription responses",
             "type": "boolean"
           },
+          "subHideSettings": {
+            "description": "Hide server settings in happ subscription (Only for Happ)",
+            "type": "boolean"
+          },
           "subJsonEnable": {
             "description": "Enable JSON subscription endpoint",
             "type": "boolean"
@@ -439,6 +443,7 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subHideSettings",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
@@ -698,6 +703,10 @@
             "description": "Encrypt subscription responses",
             "type": "boolean"
           },
+          "subHideSettings": {
+            "description": "Hide server settings in happ subscription (Only for Happ)",
+            "type": "boolean"
+          },
           "subJsonEnable": {
             "description": "Enable JSON subscription endpoint",
             "type": "boolean"
@@ -922,6 +931,7 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subHideSettings",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",

+ 6 - 5
internal/sub/clash_service.go

@@ -163,13 +163,14 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
 		}}
 	}
 	delete(stream, "externalProxy")
+	network, _ := stream["network"].(string)
 
 	proxies := make([]map[string]any, 0, len(externalProxies))
 	for _, ep := range externalProxies {
 		extPrxy := ep.(map[string]any)
 		// Expand the host's {{VAR}} remark template for this client (no-op for
 		// the synthetic/legacy entry) before it becomes the proxy name.
-		subReq.renderHostRemark(inbound, client, extPrxy)
+		subReq.renderHostRemark(inbound, client, extPrxy, network)
 		workingInbound := *inbound
 		workingInbound.Listen = extPrxy["dest"].(string)
 		workingInbound.Port = int(extPrxy["port"].(float64))
@@ -214,14 +215,14 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 		return s.buildHysteriaProxy(subReq, inbound, client, ep)
 	}
 
+	network, _ := stream["network"].(string)
+
 	proxy := map[string]any{
-		"name":   subReq.endpointRemark(inbound, client.Email, ep),
+		"name":   subReq.endpointRemark(inbound, client.Email, ep, network),
 		"server": inbound.Listen,
 		"port":   inbound.Port,
 		"udp":    true,
 	}
-
-	network, _ := stream["network"].(string)
 	if !s.applyTransport(proxy, network, stream) {
 		return nil
 	}
@@ -298,7 +299,7 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 	}
 
 	proxy := map[string]any{
-		"name":   subReq.endpointRemark(inbound, client.Email, ep),
+		"name":   subReq.endpointRemark(inbound, client.Email, ep, "quic"),
 		"type":   proxyType,
 		"server": inbound.Listen,
 		"port":   inbound.Port,

+ 2 - 2
internal/sub/endpoint.go

@@ -103,7 +103,7 @@ func (s *SubService) buildEndpointLinks(
 }
 
 // buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
-func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string) string {
+func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
 	var links strings.Builder
 	for index, e := range eps {
 		securityToApply, _ := baseObj["tls"].(string)
@@ -111,7 +111,7 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
 			securityToApply = e.ForceTls
 		}
 		newObj := cloneVmessShareObj(baseObj, e.ForceTls)
-		newObj["ps"] = s.endpointRemark(inbound, email, e.ep)
+		newObj["ps"] = s.endpointRemark(inbound, email, e.ep, transport)
 		newObj["add"] = e.Address
 		newObj["port"] = e.Port
 		if e.ForceTls != "same" {

+ 2 - 2
internal/sub/endpoint_test.go

@@ -81,7 +81,7 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
 	}
 	got := s.buildEndpointLinks(eps, params, "tls",
 		func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
-		func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark) },
+		func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
 	)
 	want := "vless://[email protected]:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" +
 		"vless://[email protected]:80?security=none&type=tcp#ib-B"
@@ -104,7 +104,7 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
 		externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
 		externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
 	}
-	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user")
+	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
 	want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
 		"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
 	if got != want {

+ 7 - 5
internal/sub/host_sub.go

@@ -197,13 +197,15 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
 	if len(eps) == 0 {
 		return ""
 	}
+	stream := unmarshalStreamSettings(inbound.StreamSettings)
+	transport, _ := stream["network"].(string)
 	// Clone each ep before expanding its remark template: the eps slice is
 	// shared across all clients of this inbound, so the rendered (per-client)
 	// remark must not leak into the next client's links.
 	rendered := make([]map[string]any, len(eps))
 	for i, ep := range eps {
 		cp := maps.Clone(ep)
-		s.renderHostRemark(inbound, client, cp)
+		s.renderHostRemark(inbound, client, cp, transport)
 		rendered[i] = cp
 	}
 	clone := *inbound
@@ -216,12 +218,12 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
 // renderers emit it verbatim (via endpointRemark) instead of re-composing it.
 // No-op for non-host endpoints (legacy externalProxy / synthetic default), so
 // their output stays byte-identical.
-func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any) {
+func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any, transport string) {
 	if !isHostEndpoint(ep) {
 		return
 	}
 	tmpl, _ := ep["remark"].(string)
-	ep["remark"] = s.genHostRemark(inbound, client, tmpl)
+	ep["remark"] = s.genHostRemark(inbound, client, tmpl, transport)
 	ep["remarkFinal"] = true
 }
 
@@ -229,7 +231,7 @@ func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Clien
 // entry. A host endpoint whose template was pre-expanded by renderHostRemark
 // carries remarkFinal and is used verbatim; every other entry flows through the
 // standard genRemark composition unchanged.
-func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any) string {
+func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any, transport string) string {
 	if ep != nil {
 		if final, _ := ep["remarkFinal"].(bool); final {
 			r, _ := ep["remark"].(string)
@@ -240,7 +242,7 @@ func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map
 	if ep != nil {
 		extra, _ = ep["remark"].(string)
 	}
-	return s.genRemark(inbound, email, extra)
+	return s.genRemark(inbound, email, extra, transport)
 }
 
 // applyEndpointHostPath overrides the transport host header / path for a host

+ 4 - 2
internal/sub/json_service.go

@@ -174,12 +174,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 	}
 
 	delete(stream, "externalProxy")
+	network, _ := stream["network"].(string)
 
 	for _, ep := range externalProxies {
 		extPrxy := ep.(map[string]any)
 		// Expand the host's {{VAR}} remark template for this client (no-op for
 		// the synthetic/legacy entry) before it's used as the config remark.
-		subReq.renderHostRemark(inbound, client, extPrxy)
+		subReq.renderHostRemark(inbound, client, extPrxy, network)
 		inbound.Listen = extPrxy["dest"].(string)
 		inbound.Port = int(extPrxy["port"].(float64))
 		newStream := cloneStreamForExternalProxy(stream)
@@ -220,8 +221,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		newConfigJson := make(map[string]any)
 		maps.Copy(newConfigJson, s.configJson)
 
+		transport, _ := newStream["network"].(string)
 		newConfigJson["outbounds"] = newOutbounds
-		newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy)
+		newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy, transport)
 
 		newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
 		newJsonArray = append(newJsonArray, newConfig)

+ 216 - 8
internal/sub/remark_vars.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"fmt"
 	"regexp"
 	"strconv"
 	"strings"
@@ -23,6 +24,7 @@ type remarkContext struct {
 	stats      xray.ClientTraffic
 	inbound    *model.Inbound
 	hostRemark string
+	transport  string
 }
 
 // configName is the display name for a link: always the inbound's own remark.
@@ -50,6 +52,54 @@ var unlimitedDropTokens = map[string]bool{
 	"TRAFFIC_LEFT":  true,
 	"TRAFFIC_TOTAL": true,
 	"DAYS_LEFT":     true,
+	"TIME_LEFT":     true,
+}
+
+// uiTokenMap translates user-friendly single-brace tokens (used in the frontend
+// Remark/Host Name fields) to their internal double-brace equivalents. Tokens
+// not present in this map are left untouched.
+var uiTokenMap = map[string]string{
+	"EMAIL":              "EMAIL",
+	"DATA_USAGE":         "TRAFFIC_USED",
+	"DATA_LEFT":          "TRAFFIC_LEFT",
+	"DATA_LIMIT":         "TRAFFIC_TOTAL",
+	"DAYS_LEFT":          "DAYS_LEFT",
+	"EXPIRE_DATE":        "EXPIRE_DATE",
+	"JALALI_EXPIRE_DATE": "JALALI_EXPIRE_DATE",
+	"TIME_LEFT":          "TIME_LEFT",
+	"STATUS_EMOJI":       "STATUS_EMOJI",
+	"USAGE_PERCENTAGE":   "USAGE_PERCENTAGE",
+	"PROTOCOL":           "PROTOCOL",
+	"TRANSPORT":          "TRANSPORT",
+}
+
+// translateUISingleBrackets converts user-friendly single-brace tokens to the
+// internal double-brace format before regex expansion. Only {TOKEN} patterns
+// that are NOT part of {{TOKEN}} are translated. Unknown tokens stay as-is.
+func translateUISingleBrackets(template string) string {
+	var result strings.Builder
+	i := 0
+	for i < len(template) {
+		if template[i] == '{' && (i == 0 || template[i-1] != '{') {
+			j := i + 1
+			for j < len(template) && template[j] != '}' {
+				j++
+			}
+			if j < len(template) && template[j] == '}' {
+				token := template[i+1 : j]
+				if internal, ok := uiTokenMap[token]; ok {
+					result.WriteString("{{")
+					result.WriteString(internal)
+					result.WriteString("}}")
+					i = j + 1
+					continue
+				}
+			}
+		}
+		result.WriteByte(template[i])
+		i++
+	}
+	return result.String()
 }
 
 // expandRemarkVars substitutes every {{TOKEN}} in template with its per-client
@@ -58,6 +108,7 @@ var unlimitedDropTokens = map[string]bool{
 // or expiry (∞) drops out whole — decoration and separator included — so an
 // unlimited client gets "host" instead of "host|📊∞|⏳∞D".
 func expandRemarkVars(template string, ctx remarkContext) string {
+	template = translateUISingleBrackets(template)
 	if !strings.Contains(template, "{{") {
 		return template
 	}
@@ -164,6 +215,21 @@ func remarkVarValue(token string, ctx remarkContext) string {
 			return strconv.Itoa(c.Reset)
 		}
 		return ""
+	case "STATUS_EMOJI":
+		return statusEmoji(st)
+	case "USAGE_PERCENTAGE":
+		return usagePercentage(st)
+	case "PROTOCOL":
+		if ctx.inbound != nil {
+			return strings.ToUpper(string(ctx.inbound.Protocol))
+		}
+		return ""
+	case "TRANSPORT":
+		return ctx.transport
+	case "TIME_LEFT":
+		return timeLeftLabel(st.ExpiryTime)
+	case "JALALI_EXPIRE_DATE":
+		return jalaliExpireDateLabel(st.ExpiryTime)
 	}
 	return ""
 }
@@ -202,13 +268,13 @@ func daysLeftLabel(expiryMs int64) string {
 	return strconv.FormatInt(days, 10)
 }
 
-// expireDateLabel renders a fixed expiry as YYYY-MM-DD (UTC). Unlimited and
-// delayed-start (no fixed calendar date yet) expiries yield "".
+// expireDateLabel renders a fixed expiry as YYYY-MM-DD (local time). Unlimited
+// and delayed-start (no fixed calendar date yet) expiries yield "".
 func expireDateLabel(expiryMs int64) string {
 	if expiryMs <= 0 {
 		return ""
 	}
-	return time.Unix(expiryMs/1000, 0).UTC().Format("2006-01-02")
+	return time.Unix(expiryMs/1000, 0).In(time.Local).Format("2006-01-02")
 }
 
 func max64(a, b int64) int64 {
@@ -218,6 +284,145 @@ func max64(a, b int64) int64 {
 	return b
 }
 
+// statusEmoji maps clientStatus to a single emoji character.
+func statusEmoji(st xray.ClientTraffic) string {
+	switch clientStatus(st) {
+	case "active":
+		return "✅"
+	case "expired":
+		return "⏳"
+	case "depleted":
+		return "🚫"
+	case "disabled":
+		return "🚫"
+	default:
+		return ""
+	}
+}
+
+// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3%").
+// Returns "" when the client has no traffic limit.
+func usagePercentage(st xray.ClientTraffic) string {
+	if st.Total <= 0 {
+		return ""
+	}
+	used := st.Up + st.Down
+	pct := float64(used) / float64(st.Total) * 100
+	if pct > 100 {
+		pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
+	}
+	return fmt.Sprintf("%.1f%%", pct)
+}
+
+// timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours
+// are zero). Returns "∞" for unlimited and "0" when past expiry.
+func timeLeftLabel(expiryMs int64) string {
+	if expiryMs == 0 {
+		return unlimitedMark
+	}
+	exp := expiryMs / 1000
+	var secs int64
+	if exp > 0 {
+		secs = exp - time.Now().Unix()
+	} else {
+		secs = -exp
+	}
+	if secs <= 0 {
+		return "0"
+	}
+	days := secs / 86400
+	hours := (secs % 86400) / 3600
+	mins := (secs % 3600) / 60
+	if days > 0 {
+		return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
+	}
+	if hours > 0 {
+		return fmt.Sprintf("%dh %dm", hours, mins)
+	}
+	return fmt.Sprintf("%dm", mins)
+}
+
+// jalaliExpireDateLabel converts a Gregorian expiry timestamp to Jalali
+// (Persian/Solar Hijri) date format "YYYY/MM/DD". Returns "" for unlimited
+// or delayed-start expiries.
+func jalaliExpireDateLabel(expiryMs int64) string {
+	if expiryMs <= 0 {
+		return ""
+	}
+	t := time.Unix(expiryMs/1000, 0).In(time.Local)
+	y, m, d := gregorianToJalali(t.Year(), int(t.Month()), t.Day())
+	return fmt.Sprintf("%d/%02d/%02d", y, m, d)
+}
+
+// gregorianToJalali converts a Gregorian date to Jalali (Solar Hijri) date.
+// Uses a reference-date approach: counts days from a known reference point
+// (2024-01-01 = 1402-10-11 JAL) and walks the Jalali calendar forward/backward.
+func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
+	// Compute Julian Day Number for the input Gregorian date
+	a := (14 - gm) / 12
+	y := gy + 4800 - a
+	m := gm + 12*a - 3
+	jdn := gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
+
+	// Reference: 2024-01-01 = JDN 2460311 = 1402-10-11 JAL
+	refJDN := 2460311
+	days := int64(jdn - refJDN)
+	jy, jm, jd = 1402, 10, 11
+
+	// Walk forward
+	for days > 0 {
+		remaining := int64(jalaliMonthDays(jy, jm) - jd + 1)
+		if days < remaining {
+			jd += int(days)
+			return
+		}
+		days -= remaining
+		jm++
+		if jm > 12 {
+			jm = 1
+			jy++
+		}
+		jd = 1
+	}
+	// Walk backward
+	for days < 0 {
+		jd += int(days)
+		for jd < 1 {
+			jm--
+			if jm < 1 {
+				jm = 12
+				jy--
+			}
+			jd += jalaliMonthDays(jy, jm)
+		}
+		days = 0
+	}
+	return
+}
+
+func jalaliMonthDays(y, m int) int {
+	if m <= 6 {
+		return 31
+	}
+	if m <= 11 {
+		return 30
+	}
+	if isJalaliLeap(y) {
+		return 30
+	}
+	return 29
+}
+
+// isJalaliLeap reports whether the given Jalali year is a leap year.
+// The leap pattern repeats every 33 years with 8 leap years.
+func isJalaliLeap(y int) bool {
+	switch y % 33 {
+	case 1, 5, 9, 13, 17, 22, 26, 30:
+		return true
+	}
+	return false
+}
+
 // statsForClient returns the client's live traffic record, or a minimal one
 // synthesized from the client (enable/expiry/total) when no live stats exist —
 // so expiry/total/status tokens still resolve on links that have no counters yet.
@@ -261,6 +466,7 @@ var usageInfoTokens = []string{
 	"TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
 	"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
 	"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
+	"STATUS_EMOJI", "USAGE_PERCENTAGE", "TIME_LEFT", "JALALI_EXPIRE_DATE",
 }
 
 // nameOnlyTemplate returns template with the trailing per-client info part
@@ -287,25 +493,27 @@ func nameOnlyTemplate(template string) string {
 // name-only template for every link thereafter — so the info shows once. Only
 // called in the subscription-body context (displays bypass the template).
 func (s *SubService) effectiveTemplate(email string) string {
+	translated := translateUISingleBrackets(s.remarkTemplate)
 	if s.usageShown == nil {
 		s.usageShown = map[string]bool{}
 	}
 	if s.usageShown[email] {
-		return nameOnlyTemplate(s.remarkTemplate)
+		return nameOnlyTemplate(translated)
 	}
 	s.usageShown[email] = true
-	return s.remarkTemplate
+	return translated
 }
 
 // genTemplatedRemark expands the remark template for one client. hostRemark is
 // the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
 // token only and never substitutes the inbound remark as the config name.
-func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
+func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
 	ctx := remarkContext{
 		client:     client,
 		stats:      s.statsForClient(inbound, client),
 		inbound:    inbound,
 		hostRemark: hostRemark,
+		transport:  transport,
 	}
 	tmpl := s.effectiveTemplate(client.Email)
 	// Fall back to the config name when the template is empty or expands to
@@ -320,9 +528,9 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 // config name is always the inbound's own remark; the host's remark is surfaced
 // only through the {{HOST}} token. In the subscription body the rest of the
 // remark template still applies; displays show just the config name.
-func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
+func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
 	if !s.subscriptionBody {
 		return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
 	}
-	return s.genTemplatedRemark(inbound, client, hostRemark)
+	return s.genTemplatedRemark(inbound, client, hostRemark, transport)
 }

+ 181 - 15
internal/sub/remark_vars_test.go

@@ -37,7 +37,6 @@ func TestExpandRemarkVars(t *testing.T) {
 
 	cases := []struct{ tmpl, want string }{
 		{"{{EMAIL}}", "[email protected]"},
-		{"{{USERNAME}}", "[email protected]"},
 		{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
 		{"{{HOST}}", ""},           // no host remark in ctx → empty
 		{"{{ID}}", client.ID},
@@ -169,10 +168,10 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
 // never substitutes it (it is reachable only through {{HOST}}).
 func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
 	s, inbound, client := hostRemarkService("") // no template → config name only
-	if got := s.genHostRemark(inbound, client, "Relay"); got != "DE" {
+	if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
 		t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
 	}
-	if got := s.genHostRemark(inbound, client, ""); got != "DE" {
+	if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
 		t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
 	}
 }
@@ -182,17 +181,17 @@ func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
 func TestGenHostRemark_GlobalTemplate(t *testing.T) {
 	// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
 	s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
-	if got := s.genHostRemark(inbound, client, "CDN"); got != "DE | 80.00GB | 10d" {
+	if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE | 80.00GB | 10d" {
 		t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
 	}
 	// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
 	s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
-	if got := s2.genHostRemark(inbound2, client2, "CDN"); got != "DE|CDN|80.00GB" {
+	if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE|CDN|80.00GB" {
 		t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
 	}
 	// {{HOST}} is the host's own remark even when the inbound has one of its own.
 	s3, inbound3, client3 := hostRemarkService("{{HOST}}")
-	if got := s3.genHostRemark(inbound3, client3, "CDN"); got != "CDN" {
+	if got := s3.genHostRemark(inbound3, client3, "CDN", ""); got != "CDN" {
 		t.Fatalf("{{HOST}} token = %q, want CDN", got)
 	}
 }
@@ -201,7 +200,7 @@ func TestGenHostRemark_GlobalTemplate(t *testing.T) {
 // legacy externalProxy remark passed as extra.
 func TestGenRemark_GlobalTemplate(t *testing.T) {
 	s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
-	got := s.genRemark(inbound, "[email protected]", "")
+	got := s.genRemark(inbound, "[email protected]", "", "")
 	if got != "[email protected] | 80.00GB" {
 		t.Fatalf("global template (non-host) = %q", got)
 	}
@@ -210,7 +209,7 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
 // With no template, genRemark composes the fallback model and adds no suffix.
 func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
 	s, inbound, _ := hostRemarkService("")
-	got := s.genRemark(inbound, "[email protected]", "Relay")
+	got := s.genRemark(inbound, "[email protected]", "Relay", "")
 	if got != "DE-Relay" {
 		t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
 	}
@@ -220,8 +219,8 @@ func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
 // link of the request; later links show the name-only template.
 func TestUsageOnFirstLinkOnly(t *testing.T) {
 	s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
-	first := s.genHostRemark(inbound, client, "")
-	second := s.genHostRemark(inbound, client, "")
+	first := s.genHostRemark(inbound, client, "", "")
+	second := s.genHostRemark(inbound, client, "", "")
 	if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
 		t.Fatalf("first link should carry usage: %q", first)
 	}
@@ -241,15 +240,15 @@ func TestRemarkInDisplayContext(t *testing.T) {
 	s.subscriptionBody = false
 	// A host link in a display shows only the config name — the inbound's remark,
 	// with no per-client email or usage info and the host remark ignored.
-	if got := s.genHostRemark(inbound, client, "CDN"); got != "DE" {
+	if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
 		t.Fatalf("display host link = %q, want config name %q", got, "DE")
 	}
 	// With no host remark, the config name is likewise the inbound's own remark.
-	if got := s.genHostRemark(inbound, client, ""); got != "DE" {
+	if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
 		t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
 	}
 	// genRemark (non-host) likewise drops the template in display context.
-	if got := s.genRemark(inbound, client.Email, ""); got != "DE" {
+	if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
 		t.Fatalf("display genRemark = %q, want %q", got, "DE")
 	}
 }
@@ -294,9 +293,176 @@ func TestStatsForClient_CrossInboundFallback(t *testing.T) {
 func TestGenHostRemark_PerClient(t *testing.T) {
 	s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
 	inbound := &model.Inbound{}
-	a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "")
-	b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "")
+	a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "", "")
+	b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "", "")
 	if a != "alice@x" || b != "bob@x" {
 		t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
 	}
 }
+
+func TestStatusEmoji(t *testing.T) {
+	cases := []struct {
+		stats xray.ClientTraffic
+		want  string
+	}{
+		{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: gb}, "✅"},
+		{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: 10 * gb, Down: 1}, "🚫"},
+		{xray.ClientTraffic{Enable: false}, "🚫"},
+		{xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "⏳"},
+	}
+	for _, c := range cases {
+		if got := statusEmoji(c.stats); got != c.want {
+			t.Errorf("statusEmoji(%+v) = %q, want %q", c.stats, got, c.want)
+		}
+	}
+}
+
+func TestUsagePercentage(t *testing.T) {
+	if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
+		t.Errorf("usagePercentage 50%% = %q", got)
+	}
+	if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
+		t.Errorf("usagePercentage unlimited = %q, want empty", got)
+	}
+	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
+		t.Errorf("usagePercentage 100%% = %q", got)
+	}
+	// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
+	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
+		t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
+	}
+}
+
+func TestTimeLeftLabel(t *testing.T) {
+	if got := timeLeftLabel(0); got != "∞" {
+		t.Errorf("timeLeftLabel(0) = %q, want ∞", got)
+	}
+	// Delayed-start: negative expiry = duration in ms. 1000ms = 1 second = "0m".
+	if got := timeLeftLabel(-1000); got != "0m" {
+		t.Errorf("timeLeftLabel(-1000) = %q, want 0m", got)
+	}
+}
+
+func TestGregorianToJalali(t *testing.T) {
+	cases := []struct {
+		gy, gm, gd int
+		jy, jm, jd int
+	}{
+		{2024, 1, 1, 1402, 10, 11},
+		{2000, 3, 20, 1379, 1, 1},
+		{1979, 2, 11, 1357, 11, 22},
+	}
+	for _, c := range cases {
+		jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
+		if jy != c.jy || jm != c.jm || jd != c.jd {
+			t.Errorf("gregorianToJalali(%d,%d,%d) = (%d,%d,%d), want (%d,%d,%d)",
+				c.gy, c.gm, c.gd, jy, jm, jd, c.jy, c.jm, c.jd)
+		}
+	}
+}
+
+func TestJalaliExpireDateLabel(t *testing.T) {
+	if got := jalaliExpireDateLabel(0); got != "" {
+		t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
+	}
+	if got := jalaliExpireDateLabel(-1000); got != "" {
+		t.Errorf("jalaliExpireDateLabel(-1000) = %q, want empty", got)
+	}
+}
+
+func TestExpandNewTokensInTemplate(t *testing.T) {
+	inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
+	client := model.Client{Email: "[email protected]", ID: "abc-123"}
+	stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
+	ctx := remarkContext{
+		client:    client,
+		stats:     stats,
+		inbound:   inbound,
+		transport: "ws",
+	}
+
+	cases := []struct{ tmpl, want string }{
+		{"{{STATUS_EMOJI}}", "✅"},
+		{"{{USAGE_PERCENTAGE}}", "50.0%"},
+		{"{{PROTOCOL}}", "VLESS"},
+		{"{{TRANSPORT}}", "ws"},
+		{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
+	}
+	for _, c := range cases {
+		if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
+			t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
+		}
+	}
+}
+
+func TestTranslateUISingleBrackets(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"{EMAIL}", "{{EMAIL}}"},
+		{"{DATA_LEFT}", "{{TRAFFIC_LEFT}}"},
+		{"{DATA_LEFT} of {DATA_LIMIT}", "{{TRAFFIC_LEFT}} of {{TRAFFIC_TOTAL}}"},
+		{"{STATUS_EMOJI} {INBOUND}", "{{STATUS_EMOJI}} {INBOUND}"},
+		{"{UNKNOWN_TOKEN}", "{UNKNOWN_TOKEN}"},
+		{"no braces", "no braces"},
+		{"{{TRAFFIC_LEFT}}", "{{TRAFFIC_LEFT}}"},
+		{"{username}", "{username}"},
+	}
+	for _, c := range cases {
+		if got := translateUISingleBrackets(c.in); got != c.want {
+			t.Errorf("translateUISingleBrackets(%q) = %q, want %q", c.in, got, c.want)
+		}
+	}
+}
+
+func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
+	inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
+	stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
+	ctx := remarkContext{
+		client:    model.Client{Email: "[email protected]"},
+		stats:     stats,
+		inbound:   inbound,
+		transport: "ws",
+	}
+	cases := []struct{ tmpl, want string }{
+		{"{EMAIL}", "[email protected]"},
+		{"{DATA_LEFT}", "50.00GB"},
+		{"{DATA_USAGE}", "50.00GB"},
+		{"{DATA_LIMIT}", "100.00GB"},
+		{"{STATUS_EMOJI}", "✅"},
+		{"{USAGE_PERCENTAGE}", "50.0%"},
+		{"{PROTOCOL}", "VLESS"},
+		{"{TRANSPORT}", "ws"},
+	}
+	for _, c := range cases {
+		if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
+			t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
+		}
+	}
+}
+
+func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
+	s := &SubService{
+		remarkTemplate:   "{STATUS_EMOJI} {{INBOUND}}|📊{{TRAFFIC_LEFT}}",
+		subscriptionBody: true,
+		usageShown:       map[string]bool{},
+	}
+	inbound := &model.Inbound{
+		Remark: "DE",
+		ClientStats: []xray.ClientTraffic{{
+			Email:  "alice@x",
+			Enable: true,
+			Total:  100 * gb,
+			Up:     20 * gb,
+			Down:   10 * gb,
+		}},
+	}
+	client := model.Client{Email: "alice@x"}
+	first := s.genTemplatedRemark(inbound, client, "", "ws")
+	s.usageShown["alice@x"] = true
+	second := s.genTemplatedRemark(inbound, client, "", "ws")
+	if !strings.Contains(first, "📊") {
+		t.Fatalf("first link should carry usage: %q", first)
+	}
+	if strings.Contains(second, "📊") {
+		t.Fatalf("second link must not carry usage: %q", second)
+	}
+}

+ 16 - 15
internal/sub/service.go

@@ -532,10 +532,10 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
 	externalProxies, _ := stream["externalProxy"].([]any)
 
 	if len(externalProxies) > 0 {
-		return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email)
+		return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email, network)
 	}
 
-	obj["ps"] = s.genRemark(inbound, email, "")
+	obj["ps"] = s.genRemark(inbound, email, "", network)
 	return buildVmessLink(obj)
 }
 
@@ -619,13 +619,13 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 				return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
-				return s.endpointRemark(inbound, email, ep)
+				return s.endpointRemark(inbound, email, ep, streamNetwork)
 			},
 		)
 	}
 
 	link := fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(address, port))
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
 }
 
 func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
@@ -670,13 +670,13 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 				return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
-				return s.endpointRemark(inbound, email, ep)
+				return s.endpointRemark(inbound, email, ep, streamNetwork)
 			},
 		)
 	}
 
 	link := fmt.Sprintf("trojan://%s@%s", password, joinHostPort(address, port))
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
 }
 
 // encodeUserinfo percent-encodes a userinfo (password/auth) value so it
@@ -763,13 +763,13 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 				return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
-				return s.endpointRemark(inbound, email, ep)
+				return s.endpointRemark(inbound, email, ep, streamNetwork)
 			},
 		)
 	}
 
 	link := fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(address, inbound.Port))
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
 }
 
 func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string {
@@ -872,7 +872,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 			applyExternalProxyHysteriaParams(ep, epParams)
 
 			link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
-			links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep)))
+			links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "quic")))
 		}
 		return strings.Join(links, "\n")
 	}
@@ -883,7 +883,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		params["mport"] = hopPorts
 	}
 	link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
 }
 
 // hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range
@@ -1494,14 +1494,15 @@ func joinAnyStrings(items []any) string {
 
 // buildVmessExternalProxyLinks is a thin adapter: it maps the legacy
 // externalProxy entries to []ShareEndpoint and renders them through the unified
-// endpoint path. Kept so genVmessLink's call site is unchanged.
-func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
+// endpoint path. Kept as a thin shim over the unified endpoint builder so
+// genVmessLink keeps calling one helper (now threading transport through).
+func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
 	eps := make([]ShareEndpoint, 0, len(externalProxies))
 	for _, externalProxy := range externalProxies {
 		ep, _ := externalProxy.(map[string]any)
 		eps = append(eps, externalProxyToEndpoint(ep))
 	}
-	return s.buildEndpointVmessLinks(eps, baseObj, inbound, email)
+	return s.buildEndpointVmessLinks(eps, baseObj, inbound, email, transport)
 }
 
 // buildLinkWithParams appends ?query and #fragment to a pre-built
@@ -1588,9 +1589,9 @@ func cloneStringMap(source map[string]string) map[string]string {
 // externalProxy / synthetic JSON-Clash entry). In the subscription body a set
 // remark template takes over; otherwise (and in every display context) the
 // remark is just the config name (inbound remark, then extra).
-func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
+func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
 	if s.remarkTemplate != "" && s.subscriptionBody {
-		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra)
+		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
 	}
 	// Sub info page + panel link/QR displays: just the config name (no template,
 	// so no per-client email/usage leaks into the shown remark).

+ 1 - 1
internal/sub/service_test.go

@@ -35,7 +35,7 @@ func TestGenRemarkOmitsNodeName(t *testing.T) {
 		nodesByID: map[int]*model.Node{7: {Id: 7, Name: "Berlin", Address: "node7.example.com"}},
 	}
 	ib := &model.Inbound{Remark: "vless-tcp", NodeID: &nodeID}
-	if got := s.genRemark(ib, "", ""); got != "vless-tcp" {
+	if got := s.genRemark(ib, "", "", ""); got != "vless-tcp" {
 		t.Fatalf("remark = %q, want %q (node name must not leak into client-visible remarks)", got, "vless-tcp")
 	}
 }