Release 3.8.2.13: iOS Packet Tunnel client and Clash URL subscriptions.
Add SwiftUI + Network Extension (LibXray/hev), parse Clash Meta YAML from subscription URLs into vless/trojan/hy2 share links, keep the selected server on connect, and ship Navis-3.8.2.13.ipa with versioned changelog. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+349
-51
@@ -95,7 +95,7 @@ func ParseBody(body string) ([]Item, error) {
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок")
|
||||
}
|
||||
|
||||
func parseText(text string) ([]Item, error) {
|
||||
@@ -105,7 +105,7 @@ func parseText(text string) ([]Item, error) {
|
||||
for _, m := range extracted {
|
||||
lines = append(lines, strings.TrimRight(m, ".,;)]}\"'"))
|
||||
}
|
||||
for _, block := range extractClashHY2(text) {
|
||||
for _, block := range extractClashProxies(text) {
|
||||
lines = append(lines, block)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ func parseText(text string) ([]Item, error) {
|
||||
continue
|
||||
}
|
||||
// Skip bare https:// without credentials (common noise in HTML/YAML).
|
||||
// Auth must be in the URI authority (before '/'), not path like Qure@master.
|
||||
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
|
||||
continue
|
||||
}
|
||||
@@ -149,7 +150,7 @@ func parseText(text string) ([]Item, error) {
|
||||
out = append(out, Item{Name: name, Protocol: detected, URI: normalized})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -159,70 +160,321 @@ func naiveLinkHasAuth(line string) bool {
|
||||
for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} {
|
||||
if strings.HasPrefix(lower, p) {
|
||||
rest := line[len(p):]
|
||||
at := strings.IndexByte(rest, '@')
|
||||
authEnd := strings.IndexByte(rest, '/')
|
||||
authority := rest
|
||||
if authEnd >= 0 {
|
||||
authority = rest[:authEnd]
|
||||
}
|
||||
at := strings.IndexByte(authority, '@')
|
||||
return at > 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractClashHY2(text string) []string {
|
||||
// Very small helper for Clash YAML hysteria2 blocks when share URI is absent.
|
||||
var reClashProxyName = regexp.MustCompile(`(?m)^[ \t]*-\s+name\s*:\s*`)
|
||||
|
||||
// extractClashProxies converts Clash Meta YAML proxy blocks into share URIs
|
||||
// (vless / trojan / hysteria2) when the subscription has no share-link lines.
|
||||
func extractClashProxies(text string) []string {
|
||||
lower := strings.ToLower(text)
|
||||
if !strings.Contains(lower, "type:") || !strings.Contains(lower, "hysteria2") {
|
||||
if !strings.Contains(lower, "type:") {
|
||||
return nil
|
||||
}
|
||||
if !strings.Contains(lower, "vless") && !strings.Contains(lower, "hysteria2") && !strings.Contains(lower, "trojan") {
|
||||
return nil
|
||||
}
|
||||
scope := text
|
||||
if sec := yamlTopSection(text, "proxies"); sec != "" {
|
||||
scope = sec
|
||||
}
|
||||
var out []string
|
||||
// Split roughly by list items.
|
||||
chunks := strings.Split(text, "\n- ")
|
||||
for _, chunk := range chunks {
|
||||
cl := strings.ToLower(chunk)
|
||||
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
|
||||
continue
|
||||
for _, chunk := range splitClashProxyBlocks(scope) {
|
||||
if link := clashBlockToShareURI(chunk); link != "" {
|
||||
out = append(out, link)
|
||||
}
|
||||
server := yamlField(chunk, "server")
|
||||
port := yamlField(chunk, "port")
|
||||
pass := yamlField(chunk, "password")
|
||||
if pass == "" {
|
||||
pass = yamlField(chunk, "auth")
|
||||
}
|
||||
if server == "" || port == "" || pass == "" {
|
||||
continue
|
||||
}
|
||||
name := yamlField(chunk, "name")
|
||||
sni := yamlField(chunk, "sni")
|
||||
obfs := yamlField(chunk, "obfs")
|
||||
obfsPass := yamlField(chunk, "obfs-password")
|
||||
if obfsPass == "" {
|
||||
obfsPass = yamlField(chunk, "obfs_password")
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "hysteria2",
|
||||
User: url.User(pass),
|
||||
Host: server + ":" + port,
|
||||
}
|
||||
q := url.Values{}
|
||||
if sni != "" {
|
||||
q.Set("sni", sni)
|
||||
}
|
||||
if obfs != "" {
|
||||
q.Set("obfs", obfs)
|
||||
}
|
||||
if obfsPass != "" {
|
||||
q.Set("obfs-password", obfsPass)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
link := u.String()
|
||||
if name != "" {
|
||||
link += "#" + url.PathEscape(name)
|
||||
}
|
||||
out = append(out, link)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitClashProxyBlocks(text string) []string {
|
||||
idxs := reClashProxyName.FindAllStringIndex(text, -1)
|
||||
if len(idxs) == 0 {
|
||||
// Legacy fallback used by older Clash dumps.
|
||||
parts := strings.Split(text, "\n- ")
|
||||
out := make([]string, 0, len(parts))
|
||||
for i, p := range parts {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, "name: "+p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := make([]string, 0, len(idxs))
|
||||
for i, idx := range idxs {
|
||||
start := idx[0]
|
||||
end := len(text)
|
||||
if i+1 < len(idxs) {
|
||||
end = idxs[i+1][0]
|
||||
}
|
||||
out = append(out, text[start:end])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clashBlockToShareURI(block string) string {
|
||||
switch strings.ToLower(yamlField(block, "type")) {
|
||||
case "hysteria2", "hy2":
|
||||
return clashHY2URI(block)
|
||||
case "vless":
|
||||
return clashVLESSURI(block)
|
||||
case "trojan":
|
||||
return clashTrojanURI(block)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func clashHY2URI(block string) string {
|
||||
server := yamlField(block, "server")
|
||||
port := yamlField(block, "port")
|
||||
pass := yamlField(block, "password")
|
||||
if pass == "" {
|
||||
pass = yamlField(block, "auth")
|
||||
}
|
||||
if server == "" || port == "" || pass == "" {
|
||||
return ""
|
||||
}
|
||||
u := url.URL{Scheme: "hysteria2", User: url.User(pass), Host: server + ":" + port}
|
||||
q := url.Values{}
|
||||
if sni := yamlField(block, "sni"); sni != "" {
|
||||
q.Set("sni", sni)
|
||||
}
|
||||
if obfs := yamlField(block, "obfs"); obfs != "" {
|
||||
q.Set("obfs", obfs)
|
||||
}
|
||||
obfsPass := yamlField(block, "obfs-password")
|
||||
if obfsPass == "" {
|
||||
obfsPass = yamlField(block, "obfs_password")
|
||||
}
|
||||
if obfsPass != "" {
|
||||
q.Set("obfs-password", obfsPass)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return withClashName(u.String(), yamlField(block, "name"))
|
||||
}
|
||||
|
||||
func clashVLESSURI(block string) string {
|
||||
server := yamlField(block, "server")
|
||||
port := yamlField(block, "port")
|
||||
uuid := yamlField(block, "uuid")
|
||||
if server == "" || port == "" || uuid == "" {
|
||||
return ""
|
||||
}
|
||||
network := strings.ToLower(yamlField(block, "network"))
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
}
|
||||
if network == "websocket" {
|
||||
network = "ws"
|
||||
}
|
||||
reality := yamlSection(block, "reality-opts")
|
||||
hasReality := reality != "" || strings.Contains(strings.ToLower(block), "reality-opts")
|
||||
tlsFlag := strings.ToLower(yamlField(block, "tls"))
|
||||
security := "none"
|
||||
switch {
|
||||
case hasReality:
|
||||
security = "reality"
|
||||
case tlsFlag == "true" || tlsFlag == "1":
|
||||
security = "tls"
|
||||
case tlsFlag == "false" || tlsFlag == "0":
|
||||
security = "none"
|
||||
default:
|
||||
if s := strings.ToLower(yamlField(block, "security")); s != "" {
|
||||
security = s
|
||||
}
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("type", network)
|
||||
q.Set("security", security)
|
||||
if sni := firstNonEmpty(yamlField(block, "servername"), yamlField(block, "sni")); sni != "" {
|
||||
q.Set("sni", sni)
|
||||
}
|
||||
if fp := yamlField(block, "client-fingerprint"); fp != "" {
|
||||
q.Set("fp", fp)
|
||||
}
|
||||
if flow := yamlField(block, "flow"); flow != "" {
|
||||
q.Set("flow", flow)
|
||||
}
|
||||
if alpn := yamlStringOrList(block, "alpn"); alpn != "" {
|
||||
q.Set("alpn", alpn)
|
||||
}
|
||||
if pe := yamlField(block, "packet-encoding"); pe != "" {
|
||||
q.Set("packetEncoding", pe)
|
||||
}
|
||||
switch network {
|
||||
case "ws":
|
||||
ws := yamlSection(block, "ws-opts")
|
||||
src := ws
|
||||
if src == "" {
|
||||
src = block
|
||||
}
|
||||
if path := yamlField(src, "path"); path != "" {
|
||||
q.Set("path", path)
|
||||
}
|
||||
if host := clashWSHost(ws); host != "" {
|
||||
q.Set("host", host)
|
||||
}
|
||||
case "grpc", "gun":
|
||||
grpc := yamlSection(block, "grpc-opts")
|
||||
if sn := firstNonEmpty(yamlField(grpc, "grpc-service-name"), yamlField(block, "servername")); sn != "" {
|
||||
q.Set("serviceName", sn)
|
||||
}
|
||||
case "xhttp", "splithttp":
|
||||
xh := yamlSection(block, "xhttp-opts")
|
||||
src := xh
|
||||
if src == "" {
|
||||
src = block
|
||||
}
|
||||
if path := yamlField(src, "path"); path != "" {
|
||||
q.Set("path", path)
|
||||
}
|
||||
if host := yamlField(xh, "host"); host != "" {
|
||||
q.Set("host", host)
|
||||
}
|
||||
if mode := yamlField(xh, "mode"); mode != "" {
|
||||
q.Set("mode", mode)
|
||||
}
|
||||
}
|
||||
if security == "reality" {
|
||||
if pbk := firstNonEmpty(yamlField(reality, "public-key"), yamlField(block, "public-key")); pbk != "" {
|
||||
q.Set("pbk", pbk)
|
||||
}
|
||||
sid := firstNonEmpty(yamlField(reality, "short-id"), yamlField(block, "short-id"))
|
||||
q.Set("sid", sid)
|
||||
if spx := yamlField(reality, "spider-x"); spx != "" {
|
||||
q.Set("spx", spx)
|
||||
}
|
||||
}
|
||||
if skip := strings.ToLower(yamlField(block, "skip-cert-verify")); skip == "true" || skip == "1" {
|
||||
q.Set("allowInsecure", "1")
|
||||
}
|
||||
u := url.URL{Scheme: "vless", User: url.User(uuid), Host: server + ":" + port, RawQuery: q.Encode()}
|
||||
return withClashName(u.String(), yamlField(block, "name"))
|
||||
}
|
||||
|
||||
func clashTrojanURI(block string) string {
|
||||
server := yamlField(block, "server")
|
||||
port := yamlField(block, "port")
|
||||
pass := yamlField(block, "password")
|
||||
if server == "" || port == "" || pass == "" {
|
||||
return ""
|
||||
}
|
||||
network := strings.ToLower(yamlField(block, "network"))
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
}
|
||||
if network == "websocket" {
|
||||
network = "ws"
|
||||
}
|
||||
security := "tls"
|
||||
if tlsFlag := strings.ToLower(yamlField(block, "tls")); tlsFlag == "false" || tlsFlag == "0" {
|
||||
security = "none"
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("type", network)
|
||||
q.Set("security", security)
|
||||
if sni := firstNonEmpty(yamlField(block, "sni"), yamlField(block, "servername")); sni != "" {
|
||||
q.Set("sni", sni)
|
||||
}
|
||||
if fp := yamlField(block, "client-fingerprint"); fp != "" {
|
||||
q.Set("fp", fp)
|
||||
}
|
||||
if alpn := yamlStringOrList(block, "alpn"); alpn != "" {
|
||||
q.Set("alpn", alpn)
|
||||
}
|
||||
if network == "ws" {
|
||||
ws := yamlSection(block, "ws-opts")
|
||||
src := ws
|
||||
if src == "" {
|
||||
src = block
|
||||
}
|
||||
if path := yamlField(src, "path"); path != "" {
|
||||
q.Set("path", path)
|
||||
}
|
||||
if host := clashWSHost(ws); host != "" {
|
||||
q.Set("host", host)
|
||||
}
|
||||
}
|
||||
if skip := strings.ToLower(yamlField(block, "skip-cert-verify")); skip == "true" || skip == "1" {
|
||||
q.Set("allowInsecure", "1")
|
||||
}
|
||||
u := url.URL{Scheme: "trojan", User: url.User(pass), Host: server + ":" + port, RawQuery: q.Encode()}
|
||||
return withClashName(u.String(), yamlField(block, "name"))
|
||||
}
|
||||
|
||||
func clashWSHost(wsOpts string) string {
|
||||
headers := yamlSection(wsOpts, "headers")
|
||||
return firstNonEmpty(yamlField(headers, "Host"), yamlField(headers, "host"), yamlField(wsOpts, "host"))
|
||||
}
|
||||
|
||||
func withClashName(link, name string) string {
|
||||
if name == "" {
|
||||
return link
|
||||
}
|
||||
return link + "#" + url.PathEscape(name)
|
||||
}
|
||||
|
||||
func yamlTopSection(text, key string) string {
|
||||
re := regexp.MustCompile(`(?im)^` + regexp.QuoteMeta(key) + `\s*:\s*(?:#.*)?$`)
|
||||
loc := re.FindStringIndex(text)
|
||||
if loc == nil {
|
||||
return ""
|
||||
}
|
||||
rest := text[loc[1]:]
|
||||
end := regexp.MustCompile(`(?m)^[A-Za-z0-9_-]+\s*:`).FindStringIndex(rest)
|
||||
if end == nil {
|
||||
return rest
|
||||
}
|
||||
return rest[:end[0]]
|
||||
}
|
||||
|
||||
func yamlSection(block, key string) string {
|
||||
lines := strings.Split(block, "\n")
|
||||
keyLower := strings.ToLower(key) + ":"
|
||||
capturing := false
|
||||
parentIndent := 0
|
||||
var out []string
|
||||
for _, line := range lines {
|
||||
indent := 0
|
||||
for _, r := range line {
|
||||
if r == ' ' || r == '\t' {
|
||||
indent++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !capturing {
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), keyLower) {
|
||||
capturing = true
|
||||
parentIndent = indent
|
||||
}
|
||||
continue
|
||||
}
|
||||
if trimmed != "" && indent <= parentIndent {
|
||||
break
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func yamlField(block, key string) string {
|
||||
re := regexp.MustCompile(`(?im)^\s*` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
|
||||
// Allow list marker: " - name: …"
|
||||
// Use [ \t]* after ':' so we do NOT consume newlines (or else "alpn:\n - h2" becomes "- h2").
|
||||
re := regexp.MustCompile(`(?im)^\s*(?:-\s+)?` + regexp.QuoteMeta(key) + `:[ \t]*["']?([^"'#\n\r]+)["']?`)
|
||||
m := re.FindStringSubmatch(block)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
@@ -230,6 +482,52 @@ func yamlField(block, key string) string {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func yamlStringOrList(block, key string) string {
|
||||
single := strings.TrimSpace(yamlField(block, key))
|
||||
// Scalar must not look like a YAML list item pulled from the next line.
|
||||
if single != "" && single != "|" && single != ">" && !strings.HasPrefix(single, "-") {
|
||||
if strings.HasPrefix(single, "[") {
|
||||
inner := strings.Trim(single, "[]")
|
||||
parts := strings.Split(inner, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.Trim(strings.TrimSpace(p), `"'`)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
return single
|
||||
}
|
||||
section := yamlSection(block, key)
|
||||
var items []string
|
||||
for _, line := range strings.Split(section, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(t, "-") {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimSpace(strings.TrimPrefix(t, "-"))
|
||||
v = strings.Trim(v, `"'`)
|
||||
if i := strings.IndexByte(v, '#'); i >= 0 {
|
||||
v = strings.TrimSpace(v[:i])
|
||||
}
|
||||
if v != "" {
|
||||
items = append(items, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(items, ",")
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeMaybeBase64(s string) (string, error) {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
|
||||
Reference in New Issue
Block a user