package render import ( "fmt" "strings" "github.com/peterqiu0516/sub-store/internal/model" ) // RenderTextProxyList renders proxies into newline-separated text lines using // the given producer. If the producer returns "" for a proxy it is skipped. // Returns an error when no supported nodes were produced. func RenderTextProxyList(proxies []model.ProxyNode, target string, producer func(model.ProxyNode) string) (string, error) { var lines []string for _, p := range proxies { line := producer(p) if line != "" { lines = append(lines, line) } } if len(lines) == 0 { return "", fmt.Errorf("No supported nodes for %s output", target) } return strings.Join(lines, "\n"), nil } // JoinTextProxy joins a proxy base line with key=value entries. // Entries with nil or empty-string values are dropped. func JoinTextProxy(base string, entries [][2]any) string { var parts []string for _, entry := range entries { key, _ := entry[0].(string) value := entry[1] if value == nil { continue } if s, ok := value.(string); ok && s == "" { continue } parts = append(parts, key+"="+FormatTextOptionValue(value)) } if len(parts) == 0 { return base } return base + "," + strings.Join(parts, ",") } // FormatTextOptionValue formats a value for a text proxy option line. func FormatTextOptionValue(value any) string { switch v := value.(type) { case bool: if v { return "true" } return "false" case []any: strs := make([]string, 0, len(v)) for _, item := range v { strs = append(strs, fmt.Sprint(item)) } return QuoteTextValue(strings.Join(strs, ",")) case []string: return QuoteTextValue(strings.Join(v, ",")) case nil: return "" default: text := fmt.Sprint(value) if strings.ContainsAny(text, ", \t\"") { return QuoteTextValue(text) } return text } } // QuoteTextValue wraps a string in double quotes, escaping inner quotes. func QuoteTextValue(value any) string { s := "" if value != nil { s = fmt.Sprint(value) } s = strings.ReplaceAll(s, `"`, `\"`) return `"` + s + `"` } // SanitizeTextProxyName replaces characters that are illegal in text proxy // names (=, comma, CR, LF) with spaces. Returns "proxy" when empty. func SanitizeTextProxyName(name string) string { r := strings.NewReplacer("=", " ", ",", " ", "\r", " ", "\n", " ") s := strings.TrimSpace(r.Replace(name)) if s == "" { return "proxy" } return s } // CommonTextOptions returns the shared option entries used by text proxy // renderers (surge, loon, qx, etc.). func CommonTextOptions(proxy model.ProxyNode) [][2]any { return [][2]any{ {"skip-cert-verify", proxy["skip-cert-verify"]}, {"udp-relay", proxy["udp"]}, {"fast-open", fastOpenValue(proxy)}, {"alpn", FormatAlpn(proxy["alpn"])}, } } func fastOpenValue(proxy model.ProxyNode) any { if v, ok := proxy["tfo"]; ok && v != nil { return v } return proxy["fast-open"] } // AppendWsOptions appends websocket transport options for surge/loon targets. func AppendWsOptions(entries [][2]any, proxy model.ProxyNode, target string) [][2]any { if getString(proxy, "network") != "ws" { return entries } wsOpts, _ := proxy["ws-opts"].(map[string]any) path := "/" if wsOpts != nil { if p, ok := wsOpts["path"].(string); ok && p != "" { path = p } } host := WsHeaderHost(wsOpts) if target == "surge" { entries = append(entries, [2]any{"ws", true}, [2]any{"ws-path", path}, [2]any{"ws-headers", host}) } else { entries = append(entries, [2]any{"path", path}, [2]any{"host", host}) } return entries } // AppendPluginOptions appends shadowsocks obfs plugin options. func AppendPluginOptions(entries [][2]any, proxy model.ProxyNode, target string) [][2]any { if getString(proxy, "plugin") != "obfs" { return entries } pluginOpts, _ := proxy["plugin-opts"].(map[string]any) if pluginOpts == nil { return entries } mode := pluginOpts["mode"] host := pluginOpts["host"] path := pluginOpts["path"] if target == "surge" { entries = append(entries, [2]any{"obfs", mode}, [2]any{"obfs-host", host}, [2]any{"obfs-uri", path}, ) } else { entries = append(entries, [2]any{"obfs-name", mode}, [2]any{"obfs-host", host}, [2]any{"obfs-uri", path}, ) } return entries } // AppendRealityOptions appends reality options for loon. func AppendRealityOptions(entries [][2]any, proxy model.ProxyNode) [][2]any { realityOpts, _ := proxy["reality-opts"].(map[string]any) var pubKey, shortID any if realityOpts != nil { pubKey = realityOpts["public-key"] shortID = realityOpts["short-id"] } return append(entries, [2]any{"public-key", pubKey}, [2]any{"short-id", shortID}) } // AppendQxRealityOptions appends reality options for Quantumult X. func AppendQxRealityOptions(entries [][2]any, proxy model.ProxyNode) [][2]any { realityOpts, _ := proxy["reality-opts"].(map[string]any) var pubKey, shortID any if realityOpts != nil { pubKey = realityOpts["public-key"] shortID = realityOpts["short-id"] } return append(entries, [2]any{"reality-base64-pubkey", pubKey}, [2]any{"reality-hex-shortid", shortID}) } // AppendQxObfs appends shadowsocks obfs options for Quantumult X. func AppendQxObfs(entries [][2]any, proxy model.ProxyNode) [][2]any { if getString(proxy, "plugin") != "obfs" { return entries } pluginOpts, _ := proxy["plugin-opts"].(map[string]any) if pluginOpts == nil { return entries } return append(entries, [2]any{"obfs", pluginOpts["mode"]}, [2]any{"obfs-host", pluginOpts["host"]}, [2]any{"obfs-uri", pluginOpts["path"]}, ) } // AppendQxTransport appends websocket transport options for Quantumult X. func AppendQxTransport(entries [][2]any, proxy model.ProxyNode) [][2]any { if getString(proxy, "network") != "ws" { return entries } wsOpts, _ := proxy["ws-opts"].(map[string]any) path := "/" if wsOpts != nil { if p, ok := wsOpts["path"].(string); ok && p != "" { path = p } } transport := "ws" if getBool(proxy, "tls") { transport = "wss" } return append(entries, [2]any{"obfs", transport}, [2]any{"obfs-uri", path}, [2]any{"obfs-host", WsHeaderHost(wsOpts)}, ) } // WsHeaderHost extracts the Host header from ws-opts headers (case-insensitive). func WsHeaderHost(wsOpts map[string]any) string { if wsOpts == nil { return "" } headers, ok := wsOpts["headers"].(map[string]any) if !ok { return "" } if v, ok := headers["Host"]; ok { return fmt.Sprint(v) } if v, ok := headers["host"]; ok { return fmt.Sprint(v) } return "" } // FormatAlpn formats an alpn value — joins array with commas or returns the string. func FormatAlpn(value any) string { switch v := value.(type) { case nil: return "" case []any: var parts []string for _, item := range v { s := fmt.Sprint(item) if s != "" { parts = append(parts, s) } } return strings.Join(parts, ",") case []string: var parts []string for _, item := range v { if item != "" { parts = append(parts, item) } } return strings.Join(parts, ",") case string: return v default: return fmt.Sprint(value) } } // --- internal helpers shared across renderers --- func getString(proxy model.ProxyNode, key string) string { v, _ := proxy[key].(string) return v } func getBool(proxy model.ProxyNode, key string) bool { switch v := proxy[key].(type) { case bool: return v case string: return v == "true" || v == "1" default: return false } } // getInt returns an int value, handling both int and float64 from JSON. func getInt(proxy model.ProxyNode, key string) int { switch v := proxy[key].(type) { case int: return v case int64: return int(v) case float64: return int(v) case string: var i int fmt.Sscanf(v, "%d", &i) return i } return 0 } // stringSetting returns the string form of a value, or "" when nil. func stringSetting(v any) string { if v == nil { return "" } return fmt.Sprint(v) } // numberOrUndefined converts a value to a number, returning 0 when nil/empty. func numberOrUndefined(v any) int { if v == nil { return 0 } switch n := v.(type) { case int: return n case int64: return int(n) case float64: return int(n) case string: if n == "" { return 0 } var i int fmt.Sscanf(n, "%d", &i) return i } return 0 } // hasValue returns true when value is non-nil and non-empty-string. func hasValue(v any) bool { if v == nil { return false } if s, ok := v.(string); ok && s == "" { return false } return true }