Release 3.8.2.13: iOS Packet Tunnel client and Clash URL subscriptions.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

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:
M4
2026-08-01 22:46:32 +03:00
co-authored by Cursor
parent 8b564110d5
commit 7ceb3a5148
44 changed files with 4165 additions and 72 deletions
+3
View File
@@ -189,6 +189,9 @@ func buildStreamSettings(link Link) (map[string]any, error) {
if link.Host != "" {
xh["host"] = link.Host
}
if link.Mode != "" {
xh["mode"] = link.Mode
}
stream["xhttpSettings"] = xh
default: // tcp
stream["network"] = "tcp"
+349 -51
View File
@@ -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) {
+90 -6
View File
@@ -2,6 +2,8 @@ package subscription
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -43,18 +45,100 @@ proxies:
sni: de.example.com
obfs: salamander
obfs-password: obfspass
- name: CZ Vless
type: vless
server: cz.example.com
port: 443
uuid: 7e1c0fbf-1758-4781-97f6-ae8715ed3f60
network: xhttp
tls: true
servername: cz.example.com
alpn:
- h2
- http/1.1
xhttp-opts:
path: /xhttp
host: cz.example.com
mode: auto
client-fingerprint: chrome
- name: CZ Trojan
type: trojan
server: cz.example.com
port: 20000
password: secretpass
network: ws
tls: true
sni: cz.example.com
ws-opts:
path: /ws
proxy-groups:
- name: Auto
icon: https://cdn.jsdelivr.net/gh/Koolson/Qure@master/IconSet/Color/Proxy.png
type: select
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("got %d", len(items))
if len(items) != 3 {
t.Fatalf("got %d items: %+v", len(items), items)
}
if items[0].Protocol != "hysteria2" {
t.Fatalf("proto %s", items[0].Protocol)
byProto := map[string]int{}
var vlessURI string
for _, it := range items {
byProto[string(it.Protocol)]++
if it.Protocol == "naive" {
t.Fatalf("unexpected naive: %s %s", it.Name, it.URI)
}
if it.Protocol == "vless" {
vlessURI = it.URI
}
}
if !strings.Contains(items[0].URI, "de.example.com:443") {
t.Fatalf("uri %s", items[0].URI)
if byProto["hysteria2"] != 1 || byProto["vless"] != 1 || byProto["trojan"] != 1 {
t.Fatalf("protos %+v", byProto)
}
if !strings.Contains(items[0].URI+items[1].URI+items[2].URI, "de.example.com:443") {
t.Fatalf("missing hy2 host in %+v", items)
}
if !strings.Contains(vlessURI, "alpn=h2") {
t.Fatalf("alpn list broken in %s", vlessURI)
}
if strings.Contains(vlessURI, "alpn=-") {
t.Fatalf("alpn bled list marker in %s", vlessURI)
}
if !strings.Contains(vlessURI, "mode=auto") {
t.Fatalf("xhttp mode missing in %s", vlessURI)
}
}
func TestNaiveAuthIgnoresPathAt(t *testing.T) {
if naiveLinkHasAuth("https://cdn.jsdelivr.net/gh/Koolson/Qure@master/IconSet/Color/Proxy.png") {
t.Fatal("path @master must not count as auth")
}
if !naiveLinkHasAuth("https://user:pass@naive.example.com") {
t.Fatal("real auth required")
}
}
func TestParseBodyRealClashSubscriptionFile(t *testing.T) {
path := filepath.Join("testdata", "evilfox_clash.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Skip("optional fixture missing:", path)
}
items, err := ParseBody(string(raw))
if err != nil {
t.Fatal(err)
}
if len(items) < 80 {
t.Fatalf("expected ~89 proxies, got %d", len(items))
}
for _, it := range items {
if it.Protocol == "naive" {
t.Fatalf("naive noise: %s %s", it.Name, it.URI)
}
if strings.EqualFold(it.Name, "master") || strings.HasPrefix(strings.ToLower(it.Name), "master-") {
t.Fatalf("icon false-positive name: %s", it.Name)
}
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 12
const BuildNumber = 13
// DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"