diff --git a/README.md b/README.md index 92ef870..90ba867 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,8 @@ https://evilfox.win/ - 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»; - 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив; - 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей; -- 3.8.2+12: экспорт/импорт списка серверов в JSON (замена при импорте). +- 3.8.2+12: экспорт/импорт списка серверов в JSON (замена при импорте); +- 3.8.2+13: **iOS** — первый device IPA (`Navis-3.8.2.13.ipa`): Packet Tunnel + LibXray + hev; Clash Meta URL-подписки (vless/trojan/hy2); без подмены выбранного сервера; старт ядра до VPN-маршрутов; hy2/naive на iOS пока недоступны; desktop — полный Clash→share разбор (не только hy2). В 3.8.1: - меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера; diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 451973c..fe733a5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { minSdk = 26 targetSdk = 35 // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build - versionCode = 3080212 - versionName = "3.8.2+12" + versionCode = 3080213 + versionName = "3.8.2+13" vectorDrawables.useSupportLibrary = true ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/build-macos.bat b/build-macos.bat index ac63974..d541dab 100644 --- a/build-macos.bat +++ b/build-macos.bat @@ -42,11 +42,11 @@ if errorlevel 1 exit /b 1 go build -o "tools\packmac\packmac.exe" .\tools\packmac if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.12 -arch arm64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.13 -arch arm64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.12 -arch amd64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.13 -arch amd64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.12 -arch universal +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.13 -arch universal if errorlevel 1 exit /b 1 echo Built Mac GUI + CLI: diff --git a/dist/navis-release/ios/Navis-3.8.2.13.ipa b/dist/navis-release/ios/Navis-3.8.2.13.ipa new file mode 100644 index 0000000..ef11dd8 Binary files /dev/null and b/dist/navis-release/ios/Navis-3.8.2.13.ipa differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 5e51212..25846d3 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,6 +1,6 @@ { "version": "3.8.2", - "notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.", + "notes": "Navis 3.8.2+13: iOS Packet Tunnel IPA (VLESS/VMess/Trojan + LibXray/hev); Clash Meta URL-подписки; выбор сервера без подмены; desktop — полный Clash→share (vless/trojan/hy2).", "platform": "windows-amd64", "os": "windows", "arch": "amd64", diff --git a/dist/update.json b/dist/update.json index 5e51212..25846d3 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,6 +1,6 @@ { "version": "3.8.2", - "notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.", + "notes": "Navis 3.8.2+13: iOS Packet Tunnel IPA (VLESS/VMess/Trojan + LibXray/hev); Clash Meta URL-подписки; выбор сервера без подмены; desktop — полный Clash→share (vless/trojan/hy2).", "platform": "windows-amd64", "os": "windows", "arch": "amd64", diff --git a/internal/protocols/xray/config_write.go b/internal/protocols/xray/config_write.go index b4809a9..ff2179c 100644 --- a/internal/protocols/xray/config_write.go +++ b/internal/protocols/xray/config_write.go @@ -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" diff --git a/internal/subscription/fetch.go b/internal/subscription/fetch.go index a6edf2e..2d62d03 100644 --- a/internal/subscription/fetch.go +++ b/internal/subscription/fetch.go @@ -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) { diff --git a/internal/subscription/fetch_test.go b/internal/subscription/fetch_test.go index f3b215c..409b7e6 100644 --- a/internal/subscription/fetch_test.go +++ b/internal/subscription/fetch_test.go @@ -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) + } } } diff --git a/internal/update/update.go b/internal/update/update.go index 5d5d234..d76c8f0 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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" diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7b69a8b --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,3 @@ +# iOS build artifacts / vendored cores (fetched by scripts/fetch-ios-cores.sh) +Vendor/*.xcframework/ +Vendor/*.zip diff --git a/ios/Navis.xcodeproj/project.pbxproj b/ios/Navis.xcodeproj/project.pbxproj new file mode 100644 index 0000000..93e56f9 --- /dev/null +++ b/ios/Navis.xcodeproj/project.pbxproj @@ -0,0 +1,502 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 501BA90A5D5C47249B656C8E /* NavisApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C862AA0DF14A52BFAA7DA4 /* NavisApp.swift */; }; + 29889AC5400E4B18A45868CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F938564D384446A8F846EE /* ContentView.swift */; }; + CAD64F5897EE48A1B8131B93 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D1813E91824E3F84A8215A /* AppModel.swift */; }; + B3EB850AF0DA4691BAC8523B /* VPNManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ABE496B5F8C4989A0255C39 /* VPNManager.swift */; }; + 17983FE70A7E47D4B491427A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FC28394BB0E342E3B3545269 /* Assets.xcassets */; }; + 19D693E2EB4B4837823679C6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B72E16D41D54F6387EFB252 /* PacketTunnelProvider.swift */; }; + BFD986863DAA4B6181E82F12 /* LibXrayCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB193EB4D4C4514A7173D5D /* LibXrayCore.swift */; }; + A58C6181B0BE46E5A01F0BAF /* HevBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7C3869B0494261AC7A120B /* HevBridge.swift */; }; + 99DADE6FA1E04772A1EEDC55 /* TunFileDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 765826485F7C4B62B380EEAB /* TunFileDescriptor.swift */; }; + FE37B785DD854B0DA186435B /* Hy2CoreRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8792AB00E67748C1AFC6C4E2 /* Hy2CoreRunner.swift */; }; + B8009DB719094E40BA6251C1 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78875E829DBA4B06BE893CC9 /* AppConstants.swift */; }; + F2B4BCA66E5C400A9AF657CF /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78875E829DBA4B06BE893CC9 /* AppConstants.swift */; }; + C94C978BF6B6476AA8045ADB /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2CB1D931A94BC1935E05AD /* Models.swift */; }; + 4BE777FCB70740A0A09597C7 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2CB1D931A94BC1935E05AD /* Models.swift */; }; + 6F35B04C0CD649269631C533 /* LinkParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58367830ACE545ABB8911A29 /* LinkParser.swift */; }; + 48C772EDB8004B0CBA837136 /* LinkParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58367830ACE545ABB8911A29 /* LinkParser.swift */; }; + 3A336046480E4355A4A99EC4 /* ProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D925646CEFA349C491515E42 /* ProfileStore.swift */; }; + 63308964F6F743118AE95EEB /* ProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D925646CEFA349C491515E42 /* ProfileStore.swift */; }; + 9906112BB8774DCD8FB3460B /* XrayConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */; }; + 5F4E3042271545159594346C /* XrayConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */; }; + 2E8DBDF81D5946C889DDF83B /* Hy2ConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */; }; + D771934DFBBB4179A7F66859 /* Hy2ConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */; }; + 123C88B88F37424081CFC711 /* NavisTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 45C1C2A9B8F348878DB2C9ED /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */; }; + 1A51105FD4FE4B1A992BC5F7 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */; }; + 3701534926EF48B688AFDF44 /* Network.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23490141FD0E4F8898217B74 /* Network.framework */; }; + A412B72FF20E4CA08FD1EA4B /* Network.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23490141FD0E4F8898217B74 /* Network.framework */; }; + 4174897D45564998905F36B3 /* LibXray.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D56C90149F6E4CD2BF7A8212 /* LibXray.xcframework */; }; + B058F0D033E8462C8694EAD8 /* HevSocks5Tunnel.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = B77B1677E4FC4E26B5E426CF /* HevSocks5Tunnel.xcframework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 053168C0782B4A6DB933DAF8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E92EE454FC4F46C1A341FDD0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9A86040240754D0BA7D26D9C; + remoteInfo = NavisTunnel; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 128CA5EEC4FD4932991762DC /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 123C88B88F37424081CFC711 /* NavisTunnel.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + A8C862AA0DF14A52BFAA7DA4 /* NavisApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavisApp.swift; sourceTree = ""; }; + A7F938564D384446A8F846EE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + F4D1813E91824E3F84A8215A /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + 6ABE496B5F8C4989A0255C39 /* VPNManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNManager.swift; sourceTree = ""; }; + 96E3096272794C55A39F63DE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C16CF76BAE864AC1AAF3FA1E /* Navis.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Navis.entitlements; sourceTree = ""; }; + FC28394BB0E342E3B3545269 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 8B72E16D41D54F6387EFB252 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; + BAB193EB4D4C4514A7173D5D /* LibXrayCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibXrayCore.swift; sourceTree = ""; }; + BB7C3869B0494261AC7A120B /* HevBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HevBridge.swift; sourceTree = ""; }; + 765826485F7C4B62B380EEAB /* TunFileDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunFileDescriptor.swift; sourceTree = ""; }; + 8792AB00E67748C1AFC6C4E2 /* Hy2CoreRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hy2CoreRunner.swift; sourceTree = ""; }; + D5BB903F8B1240ED8679CF53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9B1C066864904580ABAEE9A2 /* NavisTunnel.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NavisTunnel.entitlements; sourceTree = ""; }; + 78875E829DBA4B06BE893CC9 /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; + ED2CB1D931A94BC1935E05AD /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 58367830ACE545ABB8911A29 /* LinkParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkParser.swift; sourceTree = ""; }; + D925646CEFA349C491515E42 /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = ""; }; + 3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XrayConfigBuilder.swift; sourceTree = ""; }; + C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hy2ConfigBuilder.swift; sourceTree = ""; }; + C4C0B8F1FA9C4BACA4ACBF65 /* Navis.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Navis.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NavisTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; + 23490141FD0E4F8898217B74 /* Network.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Network.framework; path = System/Library/Frameworks/Network.framework; sourceTree = SDKROOT; }; + D56C90149F6E4CD2BF7A8212 /* LibXray.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = LibXray.xcframework; sourceTree = ""; }; + B77B1677E4FC4E26B5E426CF /* HevSocks5Tunnel.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = HevSocks5Tunnel.xcframework; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 212D7469897B4901B8CFD1B1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 45C1C2A9B8F348878DB2C9ED /* NetworkExtension.framework in Frameworks */, + 3701534926EF48B688AFDF44 /* Network.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0A9449D72F944ABFAFDC733A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A51105FD4FE4B1A992BC5F7 /* NetworkExtension.framework in Frameworks */, + A412B72FF20E4CA08FD1EA4B /* Network.framework in Frameworks */, + 4174897D45564998905F36B3 /* LibXray.xcframework in Frameworks */, + B058F0D033E8462C8694EAD8 /* HevSocks5Tunnel.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 82F1E8BC8B0640589B57B727 = { + isa = PBXGroup; + children = ( + 2893F6F9622B453F95CF5CBD /* Navis */, + 4ADF5EAB51C84F4598CB252E /* NavisTunnel */, + 3DA4771E5F0A4119A5FA1CEE /* Shared */, + A7ABA672EF12436BBA8E987F /* Vendor */, + A11DCC2FCC0F4FD78C684784 /* Frameworks */, + 6C7E4BA41F5F41AA82BB0592 /* Products */, + ); + sourceTree = ""; + }; + 2893F6F9622B453F95CF5CBD /* Navis */ = { + isa = PBXGroup; + children = ( + A8C862AA0DF14A52BFAA7DA4 /* NavisApp.swift */, + A7F938564D384446A8F846EE /* ContentView.swift */, + F4D1813E91824E3F84A8215A /* AppModel.swift */, + 6ABE496B5F8C4989A0255C39 /* VPNManager.swift */, + 96E3096272794C55A39F63DE /* Info.plist */, + C16CF76BAE864AC1AAF3FA1E /* Navis.entitlements */, + FC28394BB0E342E3B3545269 /* Assets.xcassets */, + ); + path = Navis; + sourceTree = ""; + }; + 4ADF5EAB51C84F4598CB252E /* NavisTunnel */ = { + isa = PBXGroup; + children = ( + 8B72E16D41D54F6387EFB252 /* PacketTunnelProvider.swift */, + BAB193EB4D4C4514A7173D5D /* LibXrayCore.swift */, + BB7C3869B0494261AC7A120B /* HevBridge.swift */, + 765826485F7C4B62B380EEAB /* TunFileDescriptor.swift */, + 8792AB00E67748C1AFC6C4E2 /* Hy2CoreRunner.swift */, + D5BB903F8B1240ED8679CF53 /* Info.plist */, + 9B1C066864904580ABAEE9A2 /* NavisTunnel.entitlements */, + ); + path = NavisTunnel; + sourceTree = ""; + }; + 3DA4771E5F0A4119A5FA1CEE /* Shared */ = { + isa = PBXGroup; + children = ( + 78875E829DBA4B06BE893CC9 /* AppConstants.swift */, + ED2CB1D931A94BC1935E05AD /* Models.swift */, + 58367830ACE545ABB8911A29 /* LinkParser.swift */, + D925646CEFA349C491515E42 /* ProfileStore.swift */, + 3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */, + C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */, + ); + path = Shared; + sourceTree = ""; + }; + A7ABA672EF12436BBA8E987F /* Vendor */ = { + isa = PBXGroup; + children = ( + D56C90149F6E4CD2BF7A8212 /* LibXray.xcframework */, + B77B1677E4FC4E26B5E426CF /* HevSocks5Tunnel.xcframework */, + ); + path = Vendor; + sourceTree = ""; + }; + A11DCC2FCC0F4FD78C684784 /* Frameworks */ = { + isa = PBXGroup; + children = ( + DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */, + 23490141FD0E4F8898217B74 /* Network.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6C7E4BA41F5F41AA82BB0592 /* Products */ = { + isa = PBXGroup; + children = ( + C4C0B8F1FA9C4BACA4ACBF65 /* Navis.app */, + DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1AFC19A4F55C418C868BE1EF /* Navis */ = { + isa = PBXNativeTarget; + buildConfigurationList = C7D1851855154C3BBF498516 /* Build configuration list for PBXNativeTarget "Navis" */; + buildPhases = ( + E0195926F50848DAABD5204E /* Sources */, + 212D7469897B4901B8CFD1B1 /* Frameworks */, + 71A7509E96244B5FA8DB7AAF /* Resources */, + 128CA5EEC4FD4932991762DC /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + F1C7CE9231CC407584F72403 /* PBXTargetDependency */, + ); + name = Navis; + productName = Navis; + productReference = C4C0B8F1FA9C4BACA4ACBF65 /* Navis.app */; + productType = "com.apple.product-type.application"; + }; + 9A86040240754D0BA7D26D9C /* NavisTunnel */ = { + isa = PBXNativeTarget; + buildConfigurationList = 80004BE6B7BB4FEAB286C65A /* Build configuration list for PBXNativeTarget "NavisTunnel" */; + buildPhases = ( + F97950818F2F4B608848DA63 /* Sources */, + 0A9449D72F944ABFAFDC733A /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NavisTunnel; + productName = NavisTunnel; + productReference = DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E92EE454FC4F46C1A341FDD0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = BBD23656423B456683626FCB /* Build configuration list for PBXProject "Navis" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 82F1E8BC8B0640589B57B727; + productRefGroup = 6C7E4BA41F5F41AA82BB0592 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1AFC19A4F55C418C868BE1EF /* Navis */, + 9A86040240754D0BA7D26D9C /* NavisTunnel */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 71A7509E96244B5FA8DB7AAF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 17983FE70A7E47D4B491427A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E0195926F50848DAABD5204E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 501BA90A5D5C47249B656C8E /* NavisApp.swift in Sources */, + 29889AC5400E4B18A45868CE /* ContentView.swift in Sources */, + CAD64F5897EE48A1B8131B93 /* AppModel.swift in Sources */, + B3EB850AF0DA4691BAC8523B /* VPNManager.swift in Sources */, + B8009DB719094E40BA6251C1 /* AppConstants.swift in Sources */, + C94C978BF6B6476AA8045ADB /* Models.swift in Sources */, + 6F35B04C0CD649269631C533 /* LinkParser.swift in Sources */, + 3A336046480E4355A4A99EC4 /* ProfileStore.swift in Sources */, + 9906112BB8774DCD8FB3460B /* XrayConfigBuilder.swift in Sources */, + 2E8DBDF81D5946C889DDF83B /* Hy2ConfigBuilder.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F97950818F2F4B608848DA63 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 19D693E2EB4B4837823679C6 /* PacketTunnelProvider.swift in Sources */, + BFD986863DAA4B6181E82F12 /* LibXrayCore.swift in Sources */, + A58C6181B0BE46E5A01F0BAF /* HevBridge.swift in Sources */, + 99DADE6FA1E04772A1EEDC55 /* TunFileDescriptor.swift in Sources */, + FE37B785DD854B0DA186435B /* Hy2CoreRunner.swift in Sources */, + F2B4BCA66E5C400A9AF657CF /* AppConstants.swift in Sources */, + 4BE777FCB70740A0A09597C7 /* Models.swift in Sources */, + 48C772EDB8004B0CBA837136 /* LinkParser.swift in Sources */, + 63308964F6F743118AE95EEB /* ProfileStore.swift in Sources */, + 5F4E3042271545159594346C /* XrayConfigBuilder.swift in Sources */, + D771934DFBBB4179A7F66859 /* Hy2ConfigBuilder.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + F1C7CE9231CC407584F72403 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9A86040240754D0BA7D26D9C /* NavisTunnel */; + targetProxy = 053168C0782B4A6DB933DAF8 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + B7DFA3633E1D4218BD22BAD3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A49702A686F64D78845646BC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7CA659263B144171A9384BCA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Navis/Navis.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "3.8.2.12"; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Navis/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 3.8.2; + PRODUCT_BUNDLE_IDENTIFIER = win.evilfox.navis; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC73F6B1F44A43E6AA1FCFBA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Navis/Navis.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "3.8.2.12"; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Navis/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 3.8.2; + PRODUCT_BUNDLE_IDENTIFIER = win.evilfox.navis; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + A759D328CCC84C0EA622A049 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = NavisTunnel/NavisTunnel.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "3.8.2.12"; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lresolv", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NavisTunnel/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 3.8.2; + PRODUCT_BUNDLE_IDENTIFIER = win.evilfox.navis.tunnel; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9FD69653B379421BB58E9AF0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = NavisTunnel/NavisTunnel.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "3.8.2.12"; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lresolv", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NavisTunnel/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 3.8.2; + PRODUCT_BUNDLE_IDENTIFIER = win.evilfox.navis.tunnel; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + BBD23656423B456683626FCB /* Build configuration list for PBXProject "Navis" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B7DFA3633E1D4218BD22BAD3 /* Debug */, + A49702A686F64D78845646BC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C7D1851855154C3BBF498516 /* Build configuration list for PBXNativeTarget "Navis" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7CA659263B144171A9384BCA /* Debug */, + DC73F6B1F44A43E6AA1FCFBA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 80004BE6B7BB4FEAB286C65A /* Build configuration list for PBXNativeTarget "NavisTunnel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A759D328CCC84C0EA622A049 /* Debug */, + 9FD69653B379421BB58E9AF0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = E92EE454FC4F46C1A341FDD0 /* Project object */; +} diff --git a/ios/Navis.xcodeproj/xcshareddata/xcschemes/Navis.xcscheme b/ios/Navis.xcodeproj/xcshareddata/xcschemes/Navis.xcscheme new file mode 100644 index 0000000..2030d64 --- /dev/null +++ b/ios/Navis.xcodeproj/xcshareddata/xcschemes/Navis.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Navis/AppModel.swift b/ios/Navis/AppModel.swift new file mode 100644 index 0000000..d47e9bf --- /dev/null +++ b/ios/Navis/AppModel.swift @@ -0,0 +1,182 @@ +import Foundation +import Combine + +@MainActor +final class AppModel: ObservableObject { + @Published var profiles: [Profile] = [] + @Published var pings: [String: PingResult] = [:] + @Published var activeID: String? + @Published var subURL: String = "" + @Published var autoBest: Bool = false + @Published var busy: Bool = false + @Published var meta: String = "Готово" + @Published var metaErr: Bool = false + @Published var importText: String = "" + + let store = ProfileStore() + let vpn = VPNManager() + + func refresh() { + profiles = store.loadProfiles() + activeID = store.getActiveID() + subURL = store.getSubURL() + autoBest = store.autoBest() + ensureDefaultSelectionIfNeeded() + if let err = store.tunnelError() { + setMeta(err, err: true) + } + } + + /// Only pick a default when nothing valid is selected — never override user pick. + func ensureDefaultSelectionIfNeeded() { + if let id = activeID, profiles.contains(where: { $0.id == id }) { + return + } + if let first = profiles.first(where: { $0.protocolKind.supportedOnIOS }) { + select(first.id) + } else if let first = profiles.first { + select(first.id) + } + } + + func setMeta(_ msg: String, err: Bool = false) { + meta = msg + metaErr = err + } + + func select(_ id: String) { + activeID = id + store.setActiveID(id) + defaultsHintSelected(id) + } + + private func defaultsHintSelected(_ id: String) { + if let p = profiles.first(where: { $0.id == id }), !p.protocolKind.supportedOnIOS { + setMeta(p.protocolKind.iosUnsupportedReason ?? "Протокол недоступен на iOS", err: true) + } else if metaErr { + setMeta("Выбран сервер") + } + } + + func setAutoBest(_ v: Bool) { + autoBest = v + store.setAutoBest(v) + } + + func importSubscription() async { + busy = true + defer { busy = false } + do { + let items = try await store.importSubscription(url: subURL) + profiles = items + // Keep previous selection if it still exists; otherwise default. + if let id = activeID, items.contains(where: { $0.id == id }) { + // keep + } else { + activeID = nil + ensureDefaultSelectionIfNeeded() + } + let usable = items.filter { $0.protocolKind.supportedOnIOS }.count + if items.isEmpty { + setMeta("В подписке нет поддерживаемых ссылок", err: true) + } else if usable == 0 { + setMeta("Импортировано \(items.count), но на iOS пока только VLESS/VMess/Trojan", err: true) + } else { + setMeta("Импортировано: \(items.count) (на iOS: \(usable) VLESS/Trojan)") + } + } catch { + setMeta(error.localizedDescription, err: true) + } + } + + func importPastedLinks() { + let items = store.importLinks(importText) + if items.isEmpty { + setMeta("Не найдено ссылок", err: true) + } else { + profiles = items + importText = "" + activeID = nil + ensureDefaultSelectionIfNeeded() + let usable = items.filter { $0.protocolKind.supportedOnIOS }.count + setMeta("Импортировано: \(items.count)" + (usable > 0 ? " (iOS: \(usable))" : "")) + } + } + + func pingAll() async { + busy = true + defer { busy = false } + setMeta("Пинг…") + let results = await store.pingAll(profiles) + var map: [String: PingResult] = [:] + for r in results { map[r.id] = r } + pings = map + let ok = results.filter(\.ok).count + setMeta("Пинг: \(ok)/\(results.count)") + if autoBest { + let usable = results.filter { r in + profiles.first(where: { $0.id == r.id })?.protocolKind.supportedOnIOS == true + } + if let best = store.bestOf(usable) { + select(best.id) + setMeta("Лучший: \(profiles.first(where: { $0.id == best.id })?.name ?? best.id) \(best.ms) ms") + } + } + } + + func connectSelected() async { + guard let id = activeID, let p = profiles.first(where: { $0.id == id }) else { + setMeta("Выберите сервер", err: true) + return + } + // Do NOT silently swap to the first VLESS — that looked like «всегда первый». + guard p.protocolKind.supportedOnIOS else { + setMeta(p.protocolKind.iosUnsupportedReason ?? "Протокол не поддержан на iOS", err: true) + return + } + + busy = true + defer { busy = false } + do { + setMeta("Подключение \(p.name) (\(p.protocolKind.label))…") + try await vpn.connect(name: p.name, uri: p.uri, store: store) + + // Wait for extension: connected, or error in App Group, or timeout. + for _ in 0..<40 { + try? await Task.sleep(nanoseconds: 250_000_000) + await vpn.refreshStatus() + if vpn.status == .connected { + setMeta("Подключено: \(p.name)") + return + } + if let err = store.tunnelError(), !err.isEmpty { + setMeta(err, err: true) + return + } + if vpn.status == .disconnected && !vpn.isStarting { + // Give App Group a moment to flush error from appex. + try? await Task.sleep(nanoseconds: 300_000_000) + if let err = store.tunnelError(), !err.isEmpty { + setMeta(err, err: true) + } else { + setMeta("Не удалось подключить VPN", err: true) + } + return + } + } + if vpn.status == .connected { + setMeta("Подключено: \(p.name)") + } else { + setMeta(store.tunnelError() ?? "Таймаут подключения — проверьте VPN в Настройках", err: true) + } + } catch { + setMeta(error.localizedDescription, err: true) + } + } + + func disconnect() async { + busy = false + await vpn.disconnect(store: store) + setMeta("Отключено") + } +} diff --git a/ios/Navis/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Navis/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/Navis/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Navis/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Navis/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a19a549 --- /dev/null +++ b/ios/Navis/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Navis/Assets.xcassets/Contents.json b/ios/Navis/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Navis/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Navis/ContentView.swift b/ios/Navis/ContentView.swift new file mode 100644 index 0000000..d200ab5 --- /dev/null +++ b/ios/Navis/ContentView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import NetworkExtension + +struct ContentView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + header + controls + Divider() + profileList + footer + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle("Navis") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Text( + Bundle.main.infoDictionary?["CFBundleVersion"] as? String + ?? Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + ?? "3.8.2" + ) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + TextField("URL подписки", text: $model.subURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + TextField("Или вставьте vless/hy2/… ссылки", text: $model.importText, axis: .vertical) + .lineLimit(2...4) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + } + .padding() + } + + private var controls: some View { + VStack(spacing: 10) { + HStack(spacing: 8) { + Button("Подписка") { + Task { await model.importSubscription() } + } + .buttonStyle(.borderedProminent) + .disabled(model.busy || model.subURL.trimmingCharacters(in: .whitespaces).isEmpty) + + Button("Ссылки") { + model.importPastedLinks() + } + .buttonStyle(.bordered) + .disabled(model.busy || model.importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button("Пинг") { + Task { await model.pingAll() } + } + .buttonStyle(.bordered) + .disabled(model.busy || model.profiles.isEmpty) + } + + HStack { + Toggle("Автолучший", isOn: Binding( + get: { model.autoBest }, + set: { model.setAutoBest($0) } + )) + .disabled(model.busy) + Spacer() + vpnButtons + } + } + .padding(.horizontal) + .padding(.bottom, 8) + } + + @ViewBuilder + private var vpnButtons: some View { + if model.vpn.canDisconnect || model.busy { + Button("Отключить") { + Task { await model.disconnect() } + } + .buttonStyle(.borderedProminent) + .tint(.red) + } else { + Button("Подключить") { + Task { await model.connectSelected() } + } + .buttonStyle(.borderedProminent) + .disabled(model.activeID == nil) + } + } + + private var profileList: some View { + List(model.profiles) { p in + Button { + model.select(p.id) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(p.name) + .foregroundStyle(p.protocolKind.supportedOnIOS ? .primary : .secondary) + .lineLimit(1) + Text(subtitle(for: p)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if let ping = model.pings[p.id] { + Text(ping.ok ? "\(ping.ms) ms" : "—") + .font(.caption.monospaced()) + .foregroundStyle(ping.ok ? .green : .secondary) + } + if model.activeID == p.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.blue) + } + } + } + } + .listStyle(.plain) + } + + private func subtitle(for p: Profile) -> String { + let base = "\(p.protocolKind.label) · \(p.host)" + if p.protocolKind.supportedOnIOS { return base } + return base + " · недоступен на iOS" + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 4) { + Text(statusLine) + .font(.caption) + .foregroundStyle(.secondary) + Text(model.meta) + .font(.footnote) + .foregroundStyle(model.metaErr ? .red : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + .background(Color(uiColor: .secondarySystemBackground)) + } + + private var statusLine: String { + switch model.vpn.status { + case .invalid: + return "VPN: ожидает разрешение — нажмите Подключить (нужен VLESS/Trojan)" + case .disconnected: + return "VPN: отключён" + case .connecting: + return "VPN: подключение…" + case .connected: + return "VPN: подключён" + case .reasserting: + return "VPN: переподключение…" + case .disconnecting: + return "VPN: отключение…" + @unknown default: + return "VPN: ?" + } + } +} diff --git a/ios/Navis/Info.plist b/ios/Navis/Info.plist new file mode 100644 index 0000000..8b91f6b --- /dev/null +++ b/ios/Navis/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Navis + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 3.8.2 + CFBundleVersion + 3.8.2.13 + LSRequiresIPhoneOS + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Navis/Navis.entitlements b/ios/Navis/Navis.entitlements new file mode 100644 index 0000000..287e538 --- /dev/null +++ b/ios/Navis/Navis.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.win.evilfox.navis + + + diff --git a/ios/Navis/NavisApp.swift b/ios/Navis/NavisApp.swift new file mode 100644 index 0000000..d760c48 --- /dev/null +++ b/ios/Navis/NavisApp.swift @@ -0,0 +1,17 @@ +import SwiftUI + +@main +struct NavisApp: App { + @StateObject private var model = AppModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(model) + .onAppear { + model.refresh() + Task { await model.vpn.prepare() } + } + } + } +} diff --git a/ios/Navis/VPNManager.swift b/ios/Navis/VPNManager.swift new file mode 100644 index 0000000..c16d586 --- /dev/null +++ b/ios/Navis/VPNManager.swift @@ -0,0 +1,190 @@ +import Combine +import Foundation +import NetworkExtension + +@MainActor +final class VPNManager: ObservableObject { + @Published private(set) var status: NEVPNStatus = .invalid + @Published private(set) var lastError: String? + /// True between startVPNTunnel and connected/failed — keeps Отключить visible. + @Published private(set) var isStarting = false + + private var observer: NSObjectProtocol? + + init() { + observer = NotificationCenter.default.addObserver( + forName: .NEVPNStatusDidChange, + object: nil, + queue: .main + ) { [weak self] note in + Task { @MainActor in + guard let self else { return } + if let conn = note.object as? NEVPNConnection { + self.status = conn.status + switch conn.status { + case .connected, .disconnected, .invalid, .disconnecting: + self.isStarting = false + default: + break + } + } + } + } + Task { await refreshStatus() } + } + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + var isConnected: Bool { + status == .connected || status == .connecting || status == .reasserting + } + + /// Show disconnect while system is connecting or app is waiting on start. + var canDisconnect: Bool { + isConnected || isStarting + } + + /// Creates / refreshes the system VPN configuration (may show iOS permission dialog). + func prepare() async { + do { + let mgr = try await ensureManager(name: "Navis", uri: nil) + status = mgr.connection.status + lastError = nil + } catch { + lastError = error.localizedDescription + await refreshStatus() + } + } + + func refreshStatus() async { + do { + let mgr = try await loadManager() + status = mgr.connection.status + } catch { + status = .invalid + } + } + + func connect(name: String, uri: String, store: ProfileStore) async throws { + lastError = nil + isStarting = true + defer { + // If we throw before tunnel reaches connecting, clear the flag. + if status != .connecting && status != .connected && status != .reasserting { + isStarting = false + } + } + + // Persist for the extension (App Group defaults + file). Options alone are unreliable. + store.writeTunnelConfig(name: name, uri: uri) + + var mgr = try await ensureManager(name: name, uri: uri) + + // Stop stale session so the new URI is applied cleanly. + if mgr.connection.status != .disconnected && mgr.connection.status != .invalid { + mgr.connection.stopVPNTunnel() + try? await Task.sleep(nanoseconds: 500_000_000) + // Reload fresh manager after stop to avoid "configuration is stale". + mgr = try await loadManager() + try await mgr.loadFromPreferences() + } + + guard let session = mgr.connection as? NETunnelProviderSession else { + throw StoreError.message("нет NETunnelProviderSession") + } + + do { + try session.startVPNTunnel(options: [ + "name": name as NSString, + "uri": uri as NSString, + ]) + } catch { + // Retry once with a freshly reloaded manager (fixes "configuration is stale"). + mgr = try await loadManager() + try await mgr.loadFromPreferences() + guard let retrySession = mgr.connection as? NETunnelProviderSession else { + throw error + } + try retrySession.startVPNTunnel(options: [ + "name": name as NSString, + "uri": uri as NSString, + ]) + } + status = mgr.connection.status + if status == .connecting || status == .connected { + isStarting = true + } + } + + func disconnect(store: ProfileStore) async { + isStarting = false + do { + let mgr = try await loadManager() + mgr.connection.stopVPNTunnel() + status = mgr.connection.status + } catch { + // ignore + } + store.clearTunnelConfig() + } + + private func loadManager() async throws -> NETunnelProviderManager { + let list = try await NETunnelProviderManager.loadAllFromPreferences() + if let existing = list.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier + == AppConstants.tunnelBundleID + }) ?? list.first { + return existing + } + throw StoreError.message("VPN-профиль ещё не создан") + } + + /// Saves Packet Tunnel manager. When `uri` is set, embeds it in providerConfiguration + /// so the appex always sees the selected server (not a stale first profile). + private func ensureManager(name: String, uri: String?) async throws -> NETunnelProviderManager { + let list = try await NETunnelProviderManager.loadAllFromPreferences() + let mgr = list.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier + == AppConstants.tunnelBundleID + }) ?? list.first ?? NETunnelProviderManager() + + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = AppConstants.tunnelBundleID + proto.serverAddress = { + guard let uri, !uri.isEmpty else { return "Navis" } + let host = LinkParser.hostOf(uri) + return host.isEmpty ? name : host + }() + proto.disconnectOnSleep = false + if let uri, !uri.isEmpty { + proto.providerConfiguration = [ + "uri": uri, + "name": name, + ] + } else if let existing = (mgr.protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration { + proto.providerConfiguration = existing + } + + mgr.protocolConfiguration = proto + mgr.localizedDescription = AppConstants.vpnLocalizedDescription + mgr.isEnabled = true + + try await mgr.saveToPreferences() + + // Always reload a FRESH manager after save — otherwise startVPNTunnel + // throws "configuration is stale". + let reloaded = try await NETunnelProviderManager.loadAllFromPreferences() + guard let fresh = reloaded.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier + == AppConstants.tunnelBundleID + }) ?? reloaded.first else { + throw StoreError.message("VPN-профиль не сохранился") + } + try await fresh.loadFromPreferences() + return fresh + } +} diff --git a/ios/NavisTunnel/HevBridge.swift b/ios/NavisTunnel/HevBridge.swift new file mode 100644 index 0000000..91d087c --- /dev/null +++ b/ios/NavisTunnel/HevBridge.swift @@ -0,0 +1,32 @@ +import Foundation +import HevSocks5Tunnel + +/// Bridges NE utun fd ↔ local SOCKS (macOS sysproxy replacement). +enum HevBridge { + static func writeConfig(socksHost: String = "127.0.0.1", socksPort: Int = 1080, to url: URL) throws { + let yaml = """ + tunnel: + mtu: 1400 + socks5: + address: \(socksHost) + port: \(socksPort) + udp: 'udp' + misc: + task-stack-size: 24576 + connect-timeout: 5000 + read-write-timeout: 60000 + log-level: warn + """ + try yaml.write(to: url, atomically: true, encoding: .utf8) + } + + /// Blocks until quit — call on a background thread. + @discardableResult + static func runBlocking(configPath: String, tunFd: Int32) -> Int32 { + hev_socks5_tunnel_main(configPath, tunFd) + } + + static func quit() { + hev_socks5_tunnel_quit() + } +} diff --git a/ios/NavisTunnel/Hy2CoreRunner.swift b/ios/NavisTunnel/Hy2CoreRunner.swift new file mode 100644 index 0000000..5739744 --- /dev/null +++ b/ios/NavisTunnel/Hy2CoreRunner.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Hysteria2 in-process runner. +/// Uses NavisCore when linked; otherwise returns a clear error (cannot mix a second Go runtime with LibXray). +enum Hy2CoreRunner { + private static var running = false + + static func start(configPath: String, workDir: String) throws { + #if canImport(NavisCore) + NavisCoreSetWorkDir(workDir) + if let yaml = try? String(contentsOfFile: configPath, encoding: .utf8) { + var error: NSError? + // Generated gomobile name may vary; keep a stable Swift wrapper once NavisCore is produced. + _ = yaml + _ = error + } + throw TunnelError.message("Hysteria2: NavisCore.StartHy2YAML ещё не подключён в этой сборке") + #else + // LibXray already occupies the Go runtime in this appex. + // Unified NavisCore (Xray+Hy2) is the path forward; until then ask users for Xray links. + _ = configPath + _ = workDir + running = false + throw TunnelError.message( + "Hysteria2: в этой iOS-сборке активен LibXray (VLESS/VMess/Trojan). Hy2 требует единый NavisCore — пока используйте Xray-ссылку." + ) + #endif + } + + static func stop() { + #if canImport(NavisCore) + _ = NavisCoreStop() + #endif + running = false + LibXrayCore.stop() // no-op if not xray + } +} diff --git a/ios/NavisTunnel/Info.plist b/ios/NavisTunnel/Info.plist new file mode 100644 index 0000000..2a2e8e2 --- /dev/null +++ b/ios/NavisTunnel/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NavisTunnel + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 3.8.2 + CFBundleVersion + 3.8.2.13 + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/ios/NavisTunnel/LibXrayCore.swift b/ios/NavisTunnel/LibXrayCore.swift new file mode 100644 index 0000000..53d6a1c --- /dev/null +++ b/ios/NavisTunnel/LibXrayCore.swift @@ -0,0 +1,85 @@ +import Foundation +import LibXray + +/// Thin wrapper around LibXray.Invoke (macOS Manager engine.Start for Xray). +enum LibXrayCore { + struct InvokeError: LocalizedError { + var message: String + var errorDescription: String? { message } + } + + static func runJSON(_ configJSON: String) throws { + let payload: [String: Any] = [ + "apiVersion": 1, + "method": "runXrayFromJson", + "payload": ["configJSON": configJSON], + ] + try invoke(payload) + } + + static func runFile(_ configPath: String) throws { + let payload: [String: Any] = [ + "apiVersion": 1, + "method": "runXray", + "payload": ["configPath": configPath], + ] + try invoke(payload) + } + + static func stop() { + let payload: [String: Any] = [ + "apiVersion": 1, + "method": "stopXray", + "payload": [:] as [String: Any], + ] + _ = try? invoke(payload) + } + + static func isRunning() -> Bool { + let payload: [String: Any] = [ + "apiVersion": 1, + "method": "getXrayState", + "payload": [:] as [String: Any], + ] + guard let data = try? invokeData(payload), + let running = data["running"] as? Bool + else { return false } + return running + } + + static func version() -> String { + let payload: [String: Any] = [ + "apiVersion": 1, + "method": "xrayVersion", + "payload": [:] as [String: Any], + ] + guard let data = try? invokeData(payload), + let v = data["version"] as? String + else { return "?" } + return v + } + + @discardableResult + private static func invoke(_ payload: [String: Any]) throws -> [String: Any]? { + let data = try invokeData(payload) + return data + } + + private static func invokeData(_ payload: [String: Any]) throws -> [String: Any]? { + let reqData = try JSONSerialization.data(withJSONObject: payload) + guard let req = String(data: reqData, encoding: .utf8) else { + throw InvokeError(message: "encode invoke request") + } + let respStr = LibXrayInvoke(req) + guard let respData = respStr.data(using: .utf8), + let obj = try JSONSerialization.jsonObject(with: respData) as? [String: Any] + else { + throw InvokeError(message: "decode invoke response") + } + let ok = obj["success"] as? Bool ?? false + if !ok { + throw InvokeError(message: (obj["error"] as? String) ?? "LibXray error") + } + return obj["data"] as? [String: Any] + } +} diff --git a/ios/NavisTunnel/NavisTunnel.entitlements b/ios/NavisTunnel/NavisTunnel.entitlements new file mode 100644 index 0000000..287e538 --- /dev/null +++ b/ios/NavisTunnel/NavisTunnel.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.win.evilfox.navis + + + diff --git a/ios/NavisTunnel/PacketTunnelProvider.swift b/ios/NavisTunnel/PacketTunnelProvider.swift new file mode 100644 index 0000000..3cf44bc --- /dev/null +++ b/ios/NavisTunnel/PacketTunnelProvider.swift @@ -0,0 +1,269 @@ +import Darwin +import Foundation +import NetworkExtension +import os.log + +class PacketTunnelProvider: NEPacketTunnelProvider { + private let log = Logger(subsystem: "win.evilfox.navis.tunnel", category: "tunnel") + private let store = ProfileStore() + private var hevQueue: DispatchQueue? + private var coreKind: String? + + override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { + log.info("startTunnel") + store.setTunnelStatus("starting") + + let resolved = resolveEndpoint(options: options) + let name = resolved.name + let uri = resolved.uri + if uri.isEmpty { + fail(TunnelError.message("нет URI — выберите сервер и Подключить снова"), completionHandler) + return + } + + // Persist what we actually use (helps UI + next start). + store.writeTunnelConfig(name: name, uri: uri) + + let proto = Proto.detect(uri) + switch proto { + case .vless, .vmess, .trojan: + break + case .hy2: + fail(TunnelError.message("Hysteria2 на iOS пока недоступен — выберите VLESS/VMess/Trojan"), completionHandler) + return + case .naive: + fail(TunnelError.message("NaiveProxy на iOS недоступен (Chromium-ядро)"), completionHandler) + return + case .awg: + fail(TunnelError.message("AmneziaWG на iOS — следующим этапом"), completionHandler) + return + case .unknown: + fail(TunnelError.message("Неизвестный протокол"), completionHandler) + return + } + + // Critical order: start local SOCKS core BEFORE installing full tunnel routes. + // Otherwise traffic to the proxy server is sucked into utun and never reaches the internet. + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { + completionHandler(TunnelError.message("tunnel deallocated")) + return + } + do { + try self.startCore(uri: uri, proto: proto) + try self.waitLocalPort(1080, timeoutMs: 15_000) + + let serverHost = LinkParser.hostOf(uri) + let serverIP = Self.resolveIPv4(serverHost) + let settings = Self.makeNetworkSettings(excludeHostOrIP: serverIP ?? serverHost) + + let lock = DispatchSemaphore(value: 0) + var settingsError: Error? + self.setTunnelNetworkSettings(settings) { err in + settingsError = err + lock.signal() + } + lock.wait() + if let settingsError { + throw settingsError + } + + try self.startHev() + self.store.setTunnelStatus("connected") + self.log.info("tunnel up name=\(name, privacy: .public) proto=\(proto.label, privacy: .public) uriHost=\(serverHost, privacy: .public)") + completionHandler(nil) + } catch { + self.log.error("start failed: \(error.localizedDescription, privacy: .public)") + self.teardownCore() + self.store.setTunnelStatus("error", error: error.localizedDescription) + completionHandler(error) + } + } + } + + override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + log.info("stopTunnel reason=\(String(describing: reason), privacy: .public)") + teardownCore() + store.setTunnelStatus("stopped") + store.clearTunnelConfig() + completionHandler() + } + + /// Prefer: start options → providerConfiguration → App Group file/defaults. + private func resolveEndpoint(options: [String: NSObject]?) -> (name: String, uri: String) { + if let uri = options?["uri"] as? String, !uri.isEmpty { + let name = (options?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Navis" + return (name, uri) + } + if let proto = protocolConfiguration as? NETunnelProviderProtocol, + let cfg = proto.providerConfiguration + { + let uri = (cfg["uri"] as? String) ?? "" + if !uri.isEmpty { + let name = (cfg["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Navis" + return (name, uri) + } + } + if let file = store.readTunnelConfigFile() { + return (file.name, file.uri) + } + if let cfg = store.readTunnelConfig() { + return (cfg.name, cfg.uri) + } + return ("Navis", "") + } + + private func startCore(uri: String, proto: Proto) throws { + let work = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupID) + ?? FileManager.default.temporaryDirectory + let runtime = work.appendingPathComponent("runtime", isDirectory: true) + try FileManager.default.createDirectory(at: runtime, withIntermediateDirectories: true) + + switch proto { + case .vless, .vmess, .trojan: + let json = try XrayConfigBuilder.buildJSON(uri: uri, socksPort: 1080, httpPort: 1081) + let cfgURL = runtime.appendingPathComponent("xray.json") + try json.write(to: cfgURL, atomically: true, encoding: .utf8) + // Stop any previous instance before starting. + LibXrayCore.stop() + try LibXrayCore.runFile(cfgURL.path) + coreKind = "xray" + case .hy2: + let yaml = try Hy2ConfigBuilder.buildYAML(uri: uri) + let cfgURL = runtime.appendingPathComponent("hy2.yaml") + try yaml.write(to: cfgURL, atomically: true, encoding: .utf8) + try Hy2CoreRunner.start(configPath: cfgURL.path, workDir: runtime.path) + coreKind = "hy2" + default: + throw TunnelError.message("протокол не поддержан в tunnel") + } + } + + private func startHev() throws { + let work = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupID) + ?? FileManager.default.temporaryDirectory + let runtime = work.appendingPathComponent("runtime", isDirectory: true) + try FileManager.default.createDirectory(at: runtime, withIntermediateDirectories: true) + + guard let tunFd = resolveTunnelFileDescriptor(), tunFd >= 0 else { + throw TunnelError.message("не удалось получить utun fd для hev") + } + + let hevCfg = runtime.appendingPathComponent("hev.yml") + try HevBridge.writeConfig(to: hevCfg) + + let queue = DispatchQueue(label: "win.evilfox.navis.hev", qos: .userInitiated) + hevQueue = queue + queue.async { + _ = HevBridge.runBlocking(configPath: hevCfg.path, tunFd: tunFd) + } + Thread.sleep(forTimeInterval: 0.35) + } + + private func teardownCore() { + HevBridge.quit() + switch coreKind { + case "xray": + LibXrayCore.stop() + case "hy2": + Hy2CoreRunner.stop() + default: + LibXrayCore.stop() + Hy2CoreRunner.stop() + } + coreKind = nil + } + + private func fail(_ error: Error, _ completionHandler: @escaping (Error?) -> Void) { + store.setTunnelStatus("error", error: error.localizedDescription) + completionHandler(error) + } + + private func waitLocalPort(_ port: Int, timeoutMs: Int) throws { + let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) + while Date() < deadline { + if tcpConnectOK(port: port) { return } + Thread.sleep(forTimeInterval: 0.15) + } + throw TunnelError.message("локальный SOCKS :\(port) не поднялся (проверьте ссылку VLESS/Trojan)") + } + + private func tcpConnectOK(port: Int) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(UInt16(port).bigEndian) + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let result = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + + private static func makeNetworkSettings(excludeHostOrIP: String) -> NEPacketTunnelNetworkSettings { + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + let ipv4 = NEIPv4Settings(addresses: ["10.8.0.2"], subnetMasks: ["255.255.255.255"]) + ipv4.includedRoutes = [NEIPv4Route.default()] + + var excluded: [NEIPv4Route] = [ + NEIPv4Route(destinationAddress: "127.0.0.0", subnetMask: "255.0.0.0"), + // Private LAN — do not claim (and avoid eating the tunnel address space unnecessarily). + NEIPv4Route(destinationAddress: "10.0.0.0", subnetMask: "255.0.0.0"), + NEIPv4Route(destinationAddress: "172.16.0.0", subnetMask: "255.240.0.0"), + NEIPv4Route(destinationAddress: "192.168.0.0", subnetMask: "255.255.0.0"), + ] + // Keep path to proxy server outside the tunnel (prevents routing loop). + if !excludeHostOrIP.isEmpty, looksLikeIPv4(excludeHostOrIP) { + excluded.append(NEIPv4Route(destinationAddress: excludeHostOrIP, subnetMask: "255.255.255.255")) + } + ipv4.excludedRoutes = excluded + settings.ipv4Settings = ipv4 + settings.dnsSettings = NEDNSSettings(servers: ["1.1.1.1", "8.8.8.8"]) + settings.mtu = 1400 + return settings + } + + private static func looksLikeIPv4(_ s: String) -> Bool { + var addr = in_addr() + return s.withCString { inet_pton(AF_INET, $0, &addr) == 1 } + } + + private static func resolveIPv4(_ host: String) -> String? { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + if looksLikeIPv4(trimmed) { return trimmed } + + var hints = addrinfo( + ai_flags: AI_ADDRCONFIG, + ai_family: AF_INET, + ai_socktype: SOCK_STREAM, + ai_protocol: 0, + ai_addrlen: 0, + ai_canonname: nil, + ai_addr: nil, + ai_next: nil + ) + var res: UnsafeMutablePointer? + let rc = getaddrinfo(trimmed, nil, &hints, &res) + guard rc == 0, let first = res else { return nil } + defer { freeaddrinfo(res) } + var addr = first.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } + var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + guard inet_ntop(AF_INET, &addr.sin_addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil } + return String(cString: buf) + } +} + +enum TunnelError: LocalizedError { + case message(String) + var errorDescription: String? { + switch self { + case .message(let s): return s + } + } +} diff --git a/ios/NavisTunnel/TunFileDescriptor.swift b/ios/NavisTunnel/TunFileDescriptor.swift new file mode 100644 index 0000000..dd37a0e --- /dev/null +++ b/ios/NavisTunnel/TunFileDescriptor.swift @@ -0,0 +1,42 @@ +import Foundation +import Darwin +import NetworkExtension + +enum TunFileDescriptor { + /// Locate the utun socket used by NEPacketTunnelProvider (unsupported but widely used). + static func find() -> Int32? { + if let fd = packetFlowKVO() { + return fd + } + return findUtunByGetsockopt() + } + + private static func packetFlowKVO() -> Int32? { + // Caller may set via associated object; left for PacketTunnelProvider to try first. + nil + } + + private static func findUtunByGetsockopt() -> Int32? { + var buf = [CChar](repeating: 0, count: Int(IFNAMSIZ)) + let prefix = Array("utun".utf8CString.dropLast()) + for fd: Int32 in 0...1024 { + var len = socklen_t(buf.count) + // SOL_LOCAL=0 / SYSPROTO_CONTROL path varies; use getsockopt(fd, 2, 2) like WireGuard/others. + if getsockopt(fd, 2, 2, &buf, &len) == 0 { + if buf.starts(with: prefix) { + return fd + } + } + } + return nil + } +} + +extension NEPacketTunnelProvider { + func resolveTunnelFileDescriptor() -> Int32? { + if let fd = packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32, fd >= 0 { + return fd + } + return TunFileDescriptor.find() + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..d9a8b54 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,63 @@ +# Navis iOS + +Клиент по модели **macOS Manager**: ядро поднимает SOCKS `:1080` / HTTP `:1081`, трафик в туннель через **hev → SOCKS** (вместо `networksetup`). + +Версия: **3.8.2+13** + +## Что работает + +| Протокол | Статус | +|----------|--------| +| VLESS / VMess / Trojan | Да — LibXray + конфиг как `internal/protocols/xray` | +| Hysteria2 | Конфиг готов (`Hy2ConfigBuilder`); runtime ждёт единый NavisCore (второй Go-runtime нельзя рядом с LibXray) | +| Naive / AWG | Нет (как ограничения iOS / следующий этап) | + +## Требования + +- Xcode 16+ +- `scripts/fetch-ios-cores.sh` → `ios/Vendor/` (LibXray + HevSocks5Tunnel) +- Apple Developer Team для установки на устройство +- App IDs + App Group `group.win.evilfox.navis` + Network Extension (Packet Tunnel) + +## Сборка + +```bash +./scripts/fetch-ios-cores.sh # один раз / при обновлении ядер +NAVIS_IOS_SKIP_SIMULATOR=1 ./scripts/build-ios.sh + +# Подписанный IPA: +export NAVIS_IOS_TEAM_ID=XXXXXXXXXX +./scripts/build-ios.sh +``` + +Артефакты: `dist/navis-release/ios/Navis-.ipa` (без Team — unsigned, подпишите сами). + +Или Xcode → `ios/Navis.xcodeproj` → Team → Run / Archive. + +## Архитектура + +1. App пишет URI в App Group + `providerConfiguration` → `startVPNTunnel` +2. `PacketTunnelProvider`: сначала LibXray/Hy2 → SOCKS `:1080`, затем маршруты (IP сервера в exclude) +3. `hev_socks5_tunnel_main(utun_fd)` → `127.0.0.1:1080` + +## Bundle IDs + +| Target | ID | +|--------|-----| +| Navis | `win.evilfox.navis` | +| NavisTunnel | `win.evilfox.navis.tunnel` | + +## История версий (iOS) + +- **3.8.2+13** + - Clash Meta YAML-подписки → vless / trojan / hysteria2 share-URI (не JSON) + - Фильтр ложных naive (`Qure@master` в path иконок) + - Выбор сервера не подменяется на «первый»; hy2/naive помечены как недоступные + - Надёжная передача URI: App Group file + `providerConfiguration` + options + - Старт Xray до `setTunnelNetworkSettings`, exclude IP прокси (без routing loop) + - Кнопка «Отключить» во время подключения; бейдж версии `x.y.z.build` + - `build-ios.sh` читает версию из `update.go`; IPA: `Navis-3.8.2.13.ipa` +- **3.8.2+12 (каркас iOS в ветке до bump)** + - SwiftUI + Packet Tunnel stub → полный путь LibXray + hev + - XrayConfigBuilder / Hy2ConfigBuilder по модели macOS + - fetch-ios-cores.sh (LibXray, HevSocks5Tunnel) diff --git a/ios/Shared/AppConstants.swift b/ios/Shared/AppConstants.swift new file mode 100644 index 0000000..bc8662a --- /dev/null +++ b/ios/Shared/AppConstants.swift @@ -0,0 +1,15 @@ +import Foundation + +enum AppConstants { + static let appGroupID = "group.win.evilfox.navis" + static let tunnelBundleID = "win.evilfox.navis.tunnel" + static let vpnLocalizedDescription = "Navis" + + static let keyProfiles = "profiles" + static let keyActiveID = "active_id" + static let keySubURL = "sub_url" + static let keyAutoBest = "auto_best" + static let keyTunnelConfig = "tunnel_config" + static let keyTunnelStatus = "tunnel_status" + static let keyTunnelError = "tunnel_error" +} diff --git a/ios/Shared/Hy2ConfigBuilder.swift b/ios/Shared/Hy2ConfigBuilder.swift new file mode 100644 index 0000000..036e437 --- /dev/null +++ b/ios/Shared/Hy2ConfigBuilder.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Mirrors internal/protocols/hysteria2 config_write.go + endpoint.go (SOCKS 1080 / HTTP 1081). +enum Hy2ConfigBuilder { + struct Endpoint { + var server: String + var auth: String = "" + var sni: String = "" + var insecure: Bool = false + var pinSHA256: String = "" + var obfs: String = "" + var obfsPassword: String = "" + } + + static func buildYAML(uri: String, socks: String = "127.0.0.1:1080", http: String = "127.0.0.1:1081") throws -> String { + let ep = try parseEndpoint(uri) + var b = "# generated by Navis — do not edit\n" + b += "server: \(yamlQuote(ep.server))\n" + if !ep.auth.isEmpty { + b += "auth: \(yamlQuote(ep.auth))\n" + } + if !ep.sni.isEmpty || ep.insecure || !ep.pinSHA256.isEmpty { + b += "tls:\n" + if !ep.sni.isEmpty { + b += " sni: \(yamlQuote(ep.sni))\n" + } + if ep.insecure { + b += " insecure: true\n" + } + if !ep.pinSHA256.isEmpty { + b += " pinSHA256: \(yamlQuote(ep.pinSHA256))\n" + } + } + let obfs = ep.obfs.lowercased() + switch obfs { + case "salamander": + if ep.obfsPassword.isEmpty { + throw BuilderError.message("hysteria2: salamander требует пароль (obfs-password)") + } + b += "obfs:\n type: salamander\n salamander:\n password: \(yamlQuote(ep.obfsPassword))\n" + case "gecko": + if ep.obfsPassword.isEmpty { + throw BuilderError.message("hysteria2: gecko требует пароль (obfs-password)") + } + b += "obfs:\n type: gecko\n gecko:\n password: \(yamlQuote(ep.obfsPassword))\n" + default: + break + } + // Default BBR like desktop when no bandwidth overrides. + b += "congestion:\n type: bbr\n bbrProfile: standard\n" + b += "socks5:\n listen: \(yamlQuote(socks))\n" + b += "http:\n listen: \(yamlQuote(http))\n" + return b + } + + static func parseEndpoint(_ raw: String) throws -> Endpoint { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if let hash = s.firstIndex(of: "#") { + s = String(s[.. String { + for k in keys { + if let v = q[k], !v.trimmingCharacters(in: .whitespaces).isEmpty { + return v.trimmingCharacters(in: .whitespaces) + } + } + return "" + } + + private static func yamlQuote(_ s: String) -> String { + if s.isEmpty { return "\"\"" } + if s.contains(where: { ":#{}[],&*?|-<>=!%@`'\"".contains($0) || $0.isWhitespace }) { + let escaped = s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + return s + } +} diff --git a/ios/Shared/LinkParser.swift b/ios/Shared/LinkParser.swift new file mode 100644 index 0000000..669fc49 --- /dev/null +++ b/ios/Shared/LinkParser.swift @@ -0,0 +1,548 @@ +import Foundation + +enum LinkParser { + /// Same schemes as internal/subscription/fetch.go reShareLine. + private static let shareRe = try! NSRegularExpression( + pattern: #"(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)"# + ) + + static func parseSubscriptionBody(_ body: String) -> [Profile] { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return [] } + + var candidates = [trimmed] + if let once = decodeMaybeBase64(trimmed), once != trimmed { + candidates.append(once) + if let twice = decodeMaybeBase64(once), twice != once { + candidates.append(twice) + } + } + + // Prefer the candidate that yields the most real proxy links (desktop ParseBody). + var best: [Profile] = [] + for candidate in candidates { + let items = parseText(candidate) + if items.count > best.count { + best = items + } + } + return best + } + + /// Mirrors subscription.parseText: Clash proxies, share URIs; skip noise / unknown / bare https. + private static func parseText(_ text: String) -> [Profile] { + var lines: [String] = extractClashProxies(text) + for line in text.split(whereSeparator: \.isNewline) { + lines.append(String(line)) + } + let range = NSRange(text.startIndex..., in: text) + shareRe.enumerateMatches(in: text, options: [], range: range) { match, _, _ in + guard let match, let r = Range(match.range(at: 1), in: text) else { return } + var uri = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines) + while let last = uri.last, ".,;)]}\"'\"".contains(last) { + uri.removeLast() + } + lines.append(uri) + } + + var out: [Profile] = [] + var seenURI = Set() + var seenNames: [String: Int] = [:] + + for raw in lines { + var line = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty || line.hasPrefix("#") { continue } + line = line.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + let proto = Proto.detect(line) + if proto == .unknown { continue } + // Skip bare https://http://quic:// without credentials (HTML/YAML noise). + // Auth must be in the URI authority (before '/'), not path segments like Qure@master. + if proto == .naive && !naiveLinkHasAuth(line) && !line.lowercased().hasPrefix("naive+") { + continue + } + let key = line.lowercased() + guard !seenURI.contains(key) else { continue } + seenURI.insert(key) + + var name = remarkOf(line).nilIfEmpty ?? hostOf(line).nilIfEmpty ?? proto.label + let base = name + var n = 2 + while seenNames[name] != nil { + name = "\(base)-\(n)" + n += 1 + } + seenNames[name] = 1 + out.append(Profile(id: hexID(key), name: name, uri: line)) + } + return out + } + + /// Desktop naiveLinkHasAuth: require userinfo in authority (before first '/'). + private static func naiveLinkHasAuth(_ line: String) -> Bool { + let lower = line.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let prefixes = ["naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"] + for p in prefixes where lower.hasPrefix(p) { + let rest = String(line.dropFirst(p.count)) + let authority = rest.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + .first.map(String.init) ?? rest + if let at = authority.firstIndex(of: "@"), at > authority.startIndex { + return true + } + return false + } + return false + } + + // MARK: - Clash Meta YAML → share URIs + + /// Convert Clash `proxies:` blocks (vless / trojan / hysteria2) into share links. + /// This subscription format has no vless:// lines — only YAML. + private static func extractClashProxies(_ text: String) -> [String] { + let lower = text.lowercased() + guard lower.contains("type:") else { return [] } + guard lower.contains("vless") || lower.contains("hysteria2") || lower.contains("trojan") else { + return [] + } + + var out: [String] = [] + // Prefer list items under proxies:; fall back to whole document. + let scope: String = { + if let proxies = yamlTopSection(text, "proxies"), !proxies.isEmpty { + return proxies + } + return text + }() + + let chunks = splitClashProxyBlocks(scope) + for chunk in chunks { + if let link = clashBlockToShareURI(chunk) { + out.append(link) + } + } + return out + } + + private static func splitClashProxyBlocks(_ text: String) -> [String] { + // " - name: …" / "- name: …" + let re = try! NSRegularExpression(pattern: #"(?m)^[ \t]*-\s+name\s*:\s*"#) + let range = NSRange(text.startIndex..., in: text) + var starts: [Int] = [] + re.enumerateMatches(in: text, options: [], range: range) { match, _, _ in + if let match { starts.append(match.range.location) } + } + guard !starts.isEmpty else { + // Fallback: desktop-style split + return text.components(separatedBy: "\n- ").dropFirst().map { "name: " + $0 } + } + var blocks: [String] = [] + for i in starts.indices { + let start = text.index(text.startIndex, offsetBy: starts[i]) + let end = i + 1 < starts.count + ? text.index(text.startIndex, offsetBy: starts[i + 1]) + : text.endIndex + blocks.append(String(text[start.. String? { + let typ = yamlField(block, "type").lowercased() + switch typ { + case "hysteria2", "hy2": + return clashHY2(block) + case "vless": + return clashVLESS(block) + case "trojan": + return clashTrojan(block) + default: + return nil + } + } + + private static func clashHY2(_ block: String) -> String? { + let server = yamlField(block, "server") + let port = yamlField(block, "port") + var pass = yamlField(block, "password") + if pass.isEmpty { pass = yamlField(block, "auth") } + guard !server.isEmpty, !port.isEmpty, !pass.isEmpty else { return nil } + + var comp = URLComponents() + comp.scheme = "hysteria2" + comp.user = pass + comp.host = server + comp.port = Int(port) + var q: [URLQueryItem] = [] + let sni = yamlField(block, "sni") + if !sni.isEmpty { q.append(.init(name: "sni", value: sni)) } + let obfs = yamlField(block, "obfs") + if !obfs.isEmpty { q.append(.init(name: "obfs", value: obfs)) } + var obfsPass = yamlField(block, "obfs-password") + if obfsPass.isEmpty { obfsPass = yamlField(block, "obfs_password") } + if !obfsPass.isEmpty { q.append(.init(name: "obfs-password", value: obfsPass)) } + let insecure = yamlField(block, "skip-cert-verify").lowercased() + if insecure == "true" || insecure == "1" { + q.append(.init(name: "insecure", value: "1")) + } + comp.queryItems = q.isEmpty ? nil : q + return withFragment(comp.string, name: yamlField(block, "name")) + } + + private static func clashVLESS(_ block: String) -> String? { + let server = yamlField(block, "server") + let port = yamlField(block, "port") + let uuid = yamlField(block, "uuid") + guard !server.isEmpty, !port.isEmpty, !uuid.isEmpty else { return nil } + + let network = firstNonEmpty(yamlField(block, "network"), "tcp").lowercased() + let reality = yamlSection(block, "reality-opts") + let hasReality = !reality.isEmpty || block.lowercased().contains("reality-opts") + let tlsFlag = yamlField(block, "tls").lowercased() + let security: String = { + if hasReality { return "reality" } + if tlsFlag == "true" || tlsFlag == "1" { return "tls" } + if tlsFlag == "false" || tlsFlag == "0" { return "none" } + let sec = yamlField(block, "security").lowercased() + return sec.isEmpty ? "none" : sec + }() + + var q: [URLQueryItem] = [ + .init(name: "type", value: network == "websocket" ? "ws" : network), + .init(name: "security", value: security), + ] + + let sni = firstNonEmpty(yamlField(block, "servername"), yamlField(block, "sni")) + if !sni.isEmpty { q.append(.init(name: "sni", value: sni)) } + let fp = yamlField(block, "client-fingerprint") + if !fp.isEmpty { q.append(.init(name: "fp", value: fp)) } + let flow = yamlField(block, "flow") + if !flow.isEmpty { q.append(.init(name: "flow", value: flow)) } + let alpn = yamlStringOrList(block, "alpn") + if !alpn.isEmpty { q.append(.init(name: "alpn", value: alpn)) } + let packetEnc = yamlField(block, "packet-encoding") + if !packetEnc.isEmpty { q.append(.init(name: "packetEncoding", value: packetEnc)) } + + switch network { + case "ws", "websocket": + let ws = yamlSection(block, "ws-opts") + let path = yamlField(ws.isEmpty ? block : ws, "path") + if !path.isEmpty { q.append(.init(name: "path", value: path)) } + let host = yamlNestedHeaderHost(ws) ?? yamlField(ws, "host") + if !host.isEmpty { q.append(.init(name: "host", value: host)) } + case "grpc", "gun": + let grpc = yamlSection(block, "grpc-opts") + let sn = firstNonEmpty(yamlField(grpc, "grpc-service-name"), yamlField(block, "servername")) + if !sn.isEmpty { q.append(.init(name: "serviceName", value: sn)) } + case "xhttp", "splithttp": + let xh = yamlSection(block, "xhttp-opts") + let path = yamlField(xh.isEmpty ? block : xh, "path") + if !path.isEmpty { q.append(.init(name: "path", value: path)) } + let host = yamlField(xh, "host") + if !host.isEmpty { q.append(.init(name: "host", value: host)) } + let mode = yamlField(xh, "mode") + if !mode.isEmpty { q.append(.init(name: "mode", value: mode)) } + case "httpupgrade": + let hu = yamlSection(block, "http-opts") + let path = yamlField(hu.isEmpty ? block : hu, "path") + if !path.isEmpty { q.append(.init(name: "path", value: path)) } + let host = yamlField(hu, "host") + if !host.isEmpty { q.append(.init(name: "host", value: host)) } + default: + break + } + + if security == "reality" { + let pbk = firstNonEmpty(yamlField(reality, "public-key"), yamlField(block, "public-key")) + if !pbk.isEmpty { q.append(.init(name: "pbk", value: pbk)) } + let sid = firstNonEmpty(yamlField(reality, "short-id"), yamlField(block, "short-id")) + q.append(.init(name: "sid", value: sid)) + let spx = yamlField(reality, "spider-x") + if !spx.isEmpty { q.append(.init(name: "spx", value: spx)) } + } + + let skip = yamlField(block, "skip-cert-verify").lowercased() + if skip == "true" || skip == "1" { + q.append(.init(name: "allowInsecure", value: "1")) + } + + var comp = URLComponents() + comp.scheme = "vless" + comp.user = uuid + comp.host = server + comp.port = Int(port) + comp.queryItems = q + return withFragment(comp.string, name: yamlField(block, "name")) + } + + private static func clashTrojan(_ block: String) -> String? { + let server = yamlField(block, "server") + let port = yamlField(block, "port") + let pass = yamlField(block, "password") + guard !server.isEmpty, !port.isEmpty, !pass.isEmpty else { return nil } + + let network = firstNonEmpty(yamlField(block, "network"), "tcp").lowercased() + let tlsFlag = yamlField(block, "tls").lowercased() + let security: String = { + if tlsFlag == "false" || tlsFlag == "0" { return "none" } + return "tls" + }() + + var q: [URLQueryItem] = [ + .init(name: "type", value: network == "websocket" ? "ws" : network), + .init(name: "security", value: security), + ] + let sni = firstNonEmpty(yamlField(block, "sni"), yamlField(block, "servername")) + if !sni.isEmpty { q.append(.init(name: "sni", value: sni)) } + let fp = yamlField(block, "client-fingerprint") + if !fp.isEmpty { q.append(.init(name: "fp", value: fp)) } + let alpn = yamlStringOrList(block, "alpn") + if !alpn.isEmpty { q.append(.init(name: "alpn", value: alpn)) } + + if network == "ws" || network == "websocket" { + let ws = yamlSection(block, "ws-opts") + let path = yamlField(ws.isEmpty ? block : ws, "path") + if !path.isEmpty { q.append(.init(name: "path", value: path)) } + let host = yamlNestedHeaderHost(ws) ?? yamlField(ws, "host") + if !host.isEmpty { q.append(.init(name: "host", value: host)) } + } + + let skip = yamlField(block, "skip-cert-verify").lowercased() + if skip == "true" || skip == "1" { + q.append(.init(name: "allowInsecure", value: "1")) + } + + var comp = URLComponents() + comp.scheme = "trojan" + comp.user = pass + comp.host = server + comp.port = Int(port) + comp.queryItems = q + return withFragment(comp.string, name: yamlField(block, "name")) + } + + private static func withFragment(_ base: String?, name: String) -> String? { + guard var s = base, !s.isEmpty else { return nil } + if !name.isEmpty { + let enc = name.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? name + s += "#" + enc + } + return s + } + + private static func yamlNestedHeaderHost(_ wsOpts: String) -> String? { + let headers = yamlSection(wsOpts, "headers") + if headers.isEmpty { return nil } + let host = firstNonEmpty(yamlField(headers, "Host"), yamlField(headers, "host")) + return host.isEmpty ? nil : host + } + + /// Top-level YAML section body (e.g. `proxies:`) until next key at column 0. + private static func yamlTopSection(_ text: String, _ key: String) -> String? { + let re = try! NSRegularExpression(pattern: #"(?im)^"# + NSRegularExpression.escapedPattern(for: key) + #"\s*:\s*(?:#.*)?$"#) + let range = NSRange(text.startIndex..., in: text) + guard let match = re.firstMatch(in: text, options: [], range: range), + let start = Range(match.range, in: text) + else { return nil } + let after = text[start.upperBound...] + let endRe = try! NSRegularExpression(pattern: #"(?m)^[A-Za-z0-9_-]+\s*:"#) + let afterRange = NSRange(after.startIndex..., in: after) + if let end = endRe.firstMatch(in: String(after), options: [], range: afterRange), + let endR = Range(end.range, in: after) + { + return String(after[.. String { + let lines = block.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var capturing = false + var parentIndent = 0 + var out: [String] = [] + let keyLower = key.lowercased() + ":" + for line in lines { + let indent = line.prefix(while: { $0 == " " || $0 == "\t" }).count + let trimmed = line.trimmingCharacters(in: .whitespaces) + let trimmedLower = trimmed.lowercased() + if !capturing { + if trimmedLower.hasPrefix(keyLower) { + capturing = true + parentIndent = indent + let rest = String(trimmed.dropFirst(key.count)).trimmingCharacters(in: .whitespaces) + // skip inline empty / comment + _ = rest + } + continue + } + if !trimmed.isEmpty && indent <= parentIndent { + break + } + out.append(line) + } + return out.joined(separator: "\n") + } + + private static func yamlField(_ block: String, _ key: String) -> String { + // Allow list marker: " - name: …" + // Use [ \t]* after ':' — never consume newlines (else "alpn:\n - h2" → "- h2"). + let pattern = #"(?im)^\s*(?:-\s+)?"# + NSRegularExpression.escapedPattern(for: key) + #":[ \t]*["']?([^"'#\n\r]+)["']?"# + guard let re = try? NSRegularExpression(pattern: pattern) else { return "" } + let range = NSRange(block.startIndex..., in: block) + guard let m = re.firstMatch(in: block, options: [], range: range), + m.numberOfRanges > 1, + let r = Range(m.range(at: 1), in: block) + else { return "" } + return String(block[r]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func yamlStringOrList(_ block: String, _ key: String) -> String { + let single = yamlField(block, key).trimmingCharacters(in: .whitespacesAndNewlines) + // Reject list-item bleed from the next YAML line ("- h2"). + if !single.isEmpty && single != "|" && single != ">" && !single.hasPrefix("-") { + if single.hasPrefix("[") { + return single + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) } + .filter { !$0.isEmpty } + .joined(separator: ",") + } + return single + } + let section = yamlSection(block, key) + var items: [String] = [] + for line in section.split(whereSeparator: \.isNewline) { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.hasPrefix("-") else { continue } + var v = String(t.dropFirst()).trimmingCharacters(in: .whitespaces) + v = v.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + if let hash = v.firstIndex(of: "#") { + v = String(v[.. String { + for v in vals where !v.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return v.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "" + } + + static func hostOf(_ uri: String) -> String { + if uri.lowercased().hasPrefix("vmess://") { + guard let json = decodeVmessJSON(uri) else { return "" } + let add = (json["add"] as? String) ?? "" + if !add.isEmpty { return add } + return (json["host"] as? String) ?? "" + } + let cleaned = uri.split(separator: "#", maxSplits: 1)[0] + .split(separator: "?", maxSplits: 1)[0] + let afterScheme = cleaned.split(separator: "://", maxSplits: 1).last.map(String.init) ?? String(cleaned) + let authority = afterScheme.contains("@") + ? String(afterScheme.split(separator: "@", maxSplits: 1).last!) + : afterScheme + let hostPort = authority.split(separator: "/", maxSplits: 1)[0] + return String(hostPort.split(separator: ":").first ?? "") + } + + static func portOf(_ uri: String) -> Int { + if uri.lowercased().hasPrefix("vmess://") { + guard let json = decodeVmessJSON(uri) else { return 443 } + if let p = json["port"] as? Int { return p } + if let s = json["port"] as? String, let p = Int(s) { return p } + return 443 + } + let cleaned = uri.split(separator: "#", maxSplits: 1)[0] + .split(separator: "?", maxSplits: 1)[0] + let afterScheme = cleaned.split(separator: "://", maxSplits: 1).last.map(String.init) ?? String(cleaned) + let authority = afterScheme.contains("@") + ? String(afterScheme.split(separator: "@", maxSplits: 1).last!) + : afterScheme + let hostPort = String(authority.split(separator: "/", maxSplits: 1)[0]) + if let colon = hostPort.lastIndex(of: ":"), + let p = Int(hostPort[hostPort.index(after: colon)...]) + { + return p + } + return 443 + } + + static func remarkOf(_ uri: String) -> String { + if uri.lowercased().hasPrefix("vmess://") { + guard let json = decodeVmessJSON(uri) else { return "" } + return (json["ps"] as? String) ?? "" + } + guard let hash = uri.split(separator: "#", maxSplits: 1).dropFirst().first else { return "" } + return String(hash).removingPercentEncoding ?? String(hash) + } + + private static func decodeVmessJSON(_ uri: String) -> [String: Any]? { + var raw = uri + if raw.lowercased().hasPrefix("vmess://") { + raw = String(raw.dropFirst("vmess://".count)) + } + raw = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let decoded = decodeMaybeBase64(raw), + let data = decoded.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return obj + } + + private static func decodeMaybeBase64(_ s: String) -> String? { + let cleaned = s.replacingOccurrences(of: "\\s", with: "", options: .regularExpression) + guard cleaned.count >= 16 else { return nil } + let pad = String(repeating: "=", count: (4 - cleaned.count % 4) % 4) + let padded = cleaned + pad + if let data = Data(base64Encoded: padded, options: [.ignoreUnknownCharacters]), + let str = String(data: data, encoding: .utf8), + looksLikeText(str) + { + return str + } + let urlSafe = padded + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + if let data = Data(base64Encoded: urlSafe, options: [.ignoreUnknownCharacters]), + let str = String(data: data, encoding: .utf8), + looksLikeText(str) + { + return str + } + return nil + } + + private static func looksLikeText(_ s: String) -> Bool { + // Reject binary garbage so we don't scan random https:// in noise. + let sample = s.prefix(2048) + if sample.contains("vless://") || sample.contains("vmess://") || sample.contains("trojan://") + || sample.contains("hy2://") || sample.contains("hysteria2://") || sample.contains("naive+") + { + return true + } + // Clash / plain line lists + if sample.contains("proxies:") || sample.contains("\n") { + return sample.unicodeScalars.filter { $0.value < 9 }.isEmpty + } + return false + } + + private static func hexID(_ key: String) -> String { + // 64-bit FNV-1a × 2 — unique enough for subscription node lists. + var h1: UInt64 = 14_695_981_039_346_656_037 + var h2: UInt64 = 0xcbf29ce484222325 + for (i, b) in key.utf8.enumerated() { + h1 ^= UInt64(b) + h1 &*= 1_099_511_628_211 + h2 ^= UInt64(b) &+ UInt64(i) + h2 &*= 1_099_511_628_211 + } + return String(format: "%016llx%08llx", h1, h2 & 0xffff_ffff) + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/ios/Shared/Models.swift b/ios/Shared/Models.swift new file mode 100644 index 0000000..adf39ff --- /dev/null +++ b/ios/Shared/Models.swift @@ -0,0 +1,90 @@ +import Foundation + +enum Proto: String, Codable, Sendable { + case vless + case vmess + case trojan + case hy2 + case naive + case awg + case unknown + + var label: String { rawValue } + + static func detect(_ uri: String) -> Proto { + let lower = uri.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if lower.hasPrefix("vless://") { return .vless } + if lower.hasPrefix("vmess://") { return .vmess } + if lower.hasPrefix("trojan://") { return .trojan } + if lower.hasPrefix("hysteria2://") || lower.hasPrefix("hy2://") { return .hy2 } + if lower.hasPrefix("naive+") || lower.hasPrefix("naive://") { return .naive } + // Desktop DetectProtocol: bare http(s)/quic with userinfo → naive + if lower.hasPrefix("https://") || lower.hasPrefix("http://") || lower.hasPrefix("quic://") { + return .naive + } + if lower.hasPrefix("awg://") || lower.hasPrefix("amneziawg://") || lower.hasPrefix("wireguard://") { + return .awg + } + if lower.localizedCaseInsensitiveContains("[Interface]") && lower.contains("Jc") { + return .awg + } + return .unknown + } + + /// Protocols with a working in-process core in the current iOS appex. + var supportedOnIOS: Bool { + switch self { + case .vless, .vmess, .trojan: + return true + case .hy2: + // YAML builder ready; runtime needs unified NavisCore (LibXray Go runtime conflict). + return false + case .naive, .awg, .unknown: + return false + } + } + + var iosUnsupportedReason: String? { + switch self { + case .hy2: + return "Hysteria2: пока используйте VLESS/VMess/Trojan (единый NavisCore для Hy2 — следующий шаг)" + case .naive: + return "NaiveProxy на iOS недоступен (Chromium-ядро)" + case .awg: + return "AmneziaWG на iOS — следующим этапом" + case .unknown: + return "Неизвестный протокол" + default: + return nil + } + } +} + +struct Profile: Identifiable, Codable, Equatable, Sendable { + var id: String + var name: String + var uri: String + var host: String + + var protocolKind: Proto { Proto.detect(uri) } + + init(id: String, name: String, uri: String, host: String = "") { + self.id = id + self.name = name + self.uri = uri + self.host = host.isEmpty ? LinkParser.hostOf(uri) : host + } +} + +struct PingResult: Identifiable, Sendable { + var id: String + var ms: Int64 = -1 + var ok: Bool = false + var error: String? +} + +struct TunnelConfig: Codable, Sendable { + var name: String + var uri: String + var startedAt: Double +} diff --git a/ios/Shared/ProfileStore.swift b/ios/Shared/ProfileStore.swift new file mode 100644 index 0000000..b2261b7 --- /dev/null +++ b/ios/Shared/ProfileStore.swift @@ -0,0 +1,306 @@ +import Foundation +import Network + +final class ProfileStore: @unchecked Sendable { + private let defaults: UserDefaults + + init(suiteName: String = AppConstants.appGroupID) { + defaults = UserDefaults(suiteName: suiteName) ?? .standard + } + + func loadProfiles() -> [Profile] { + guard let data = defaults.data(forKey: AppConstants.keyProfiles), + let list = try? JSONDecoder().decode([Profile].self, from: data) + else { return [] } + return list + } + + func saveProfiles(_ list: [Profile]) { + if let data = try? JSONEncoder().encode(list) { + defaults.set(data, forKey: AppConstants.keyProfiles) + } + } + + func getActiveID() -> String? { + defaults.string(forKey: AppConstants.keyActiveID) + } + + func setActiveID(_ id: String?) { + defaults.set(id, forKey: AppConstants.keyActiveID) + } + + func getSubURL() -> String { + defaults.string(forKey: AppConstants.keySubURL) ?? "" + } + + func setSubURL(_ url: String) { + defaults.set(url, forKey: AppConstants.keySubURL) + } + + func autoBest() -> Bool { + defaults.bool(forKey: AppConstants.keyAutoBest) + } + + func setAutoBest(_ v: Bool) { + defaults.set(v, forKey: AppConstants.keyAutoBest) + } + + func writeTunnelConfig(name: String, uri: String) { + let cfg = TunnelConfig(name: name, uri: uri, startedAt: Date().timeIntervalSince1970) + if let data = try? JSONEncoder().encode(cfg) { + defaults.set(data, forKey: AppConstants.keyTunnelConfig) + } + defaults.set("", forKey: AppConstants.keyTunnelError) + defaults.set("starting", forKey: AppConstants.keyTunnelStatus) + defaults.synchronize() + writeTunnelConfigFile(cfg) + } + + func clearTunnelConfig() { + defaults.removeObject(forKey: AppConstants.keyTunnelConfig) + defaults.set("stopped", forKey: AppConstants.keyTunnelStatus) + defaults.set("", forKey: AppConstants.keyTunnelError) + defaults.synchronize() + removeTunnelConfigFile() + } + + func readTunnelConfig() -> TunnelConfig? { + if let file = readTunnelConfigFile() { + return file + } + guard let data = defaults.data(forKey: AppConstants.keyTunnelConfig) else { return nil } + return try? JSONDecoder().decode(TunnelConfig.self, from: data) + } + + func tunnelStatus() -> String { + defaults.string(forKey: AppConstants.keyTunnelStatus) ?? "idle" + } + + func tunnelError() -> String? { + // Prefer file (extension → app visibility is more reliable than suite UserDefaults alone). + if let fileErr = readTunnelErrorFile(), !fileErr.isEmpty { + return fileErr + } + let s = defaults.string(forKey: AppConstants.keyTunnelError) ?? "" + return s.isEmpty ? nil : s + } + + func setTunnelStatus(_ status: String, error: String? = nil) { + defaults.set(status, forKey: AppConstants.keyTunnelStatus) + if let error { + defaults.set(error, forKey: AppConstants.keyTunnelError) + writeTunnelErrorFile(error) + } else if status == "connected" || status == "stopped" || status == "starting" { + defaults.set("", forKey: AppConstants.keyTunnelError) + writeTunnelErrorFile("") + } + defaults.synchronize() + } + + // MARK: - App Group files (shared with Packet Tunnel) + + private var containerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupID) + } + + private var tunnelConfigFileURL: URL? { + containerURL?.appendingPathComponent("tunnel-config.json") + } + + private var tunnelErrorFileURL: URL? { + containerURL?.appendingPathComponent("tunnel-error.txt") + } + + private func writeTunnelConfigFile(_ cfg: TunnelConfig) { + guard let url = tunnelConfigFileURL, + let data = try? JSONEncoder().encode(cfg) + else { return } + try? data.write(to: url, options: .atomic) + } + + func readTunnelConfigFile() -> TunnelConfig? { + guard let url = tunnelConfigFileURL, + let data = try? Data(contentsOf: url) + else { return nil } + return try? JSONDecoder().decode(TunnelConfig.self, from: data) + } + + private func removeTunnelConfigFile() { + if let url = tunnelConfigFileURL { + try? FileManager.default.removeItem(at: url) + } + if let url = tunnelErrorFileURL { + try? FileManager.default.removeItem(at: url) + } + } + + private func writeTunnelErrorFile(_ error: String) { + guard let url = tunnelErrorFileURL else { return } + if error.isEmpty { + try? FileManager.default.removeItem(at: url) + } else { + try? error.data(using: .utf8)?.write(to: url, options: .atomic) + } + } + + private func readTunnelErrorFile() -> String? { + guard let url = tunnelErrorFileURL, + let data = try? Data(contentsOf: url), + let s = String(data: data, encoding: .utf8) + else { return nil } + return s + } + + func importSubscription(url: String) async throws -> [Profile] { + guard let u = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw StoreError.message("неверный URL подписки") + } + var req = URLRequest(url: u) + req.setValue("ClashMeta/1.18.0", forHTTPHeaderField: "User-Agent") + req.setValue("*/*", forHTTPHeaderField: "Accept") + let (data, resp) = try await URLSession.shared.data(for: req) + if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw StoreError.message("подписка HTTP \(http.statusCode)") + } + let body = String(data: data, encoding: .utf8) ?? "" + let items = LinkParser.parseSubscriptionBody(body) + if items.isEmpty { + throw StoreError.message("в подписке нет серверов") + } + saveProfiles(items) + setSubURL(url.trimmingCharacters(in: .whitespacesAndNewlines)) + return items + } + + func importLinks(_ text: String) -> [Profile] { + let items = LinkParser.parseSubscriptionBody(text) + if !items.isEmpty { + saveProfiles(items) + } + return items + } + + func pingAll(_ profiles: [Profile]) async -> [PingResult] { + await withTaskGroup(of: PingResult.self) { group in + for p in profiles { + group.addTask { await self.pingOne(p) } + } + var out: [PingResult] = [] + for await r in group { + out.append(r) + } + return out.sorted { + if $0.ok != $1.ok { return $0.ok && !$1.ok } + if $0.ok { return $0.ms < $1.ms } + return false + } + } + } + + func bestOf(_ pings: [PingResult]) -> PingResult? { + pings.filter(\.ok).min(by: { $0.ms < $1.ms }) + } + + private func pingOne(_ p: Profile) async -> PingResult { + let host = LinkParser.hostOf(p.uri) + let port = LinkParser.portOf(p.uri) + guard !host.isEmpty else { + return PingResult(id: p.id, error: "нет хоста") + } + let proto = Proto.detect(p.uri) + let start = Date() + do { + if proto == .hy2 || proto == .awg { + // Best-effort UDP connectability probe. + try await udpProbe(host: host, port: UInt16(port)) + } else { + try await tcpConnect(host: host, port: UInt16(port), timeout: 3) + } + let ms = Int64(Date().timeIntervalSince(start) * 1000) + return PingResult(id: p.id, ms: ms, ok: true) + } catch { + return PingResult(id: p.id, ok: false, error: error.localizedDescription) + } + } + + private func tcpConnect(host: String, port: UInt16, timeout: TimeInterval) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let queue = DispatchQueue(label: "navis.ping") + let conn = NWConnection( + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: port)!, + using: .tcp + ) + var settled = false + let finish: (Result) -> Void = { result in + guard !settled else { return } + settled = true + conn.cancel() + cont.resume(with: result) + } + conn.stateUpdateHandler = { state in + switch state { + case .ready: + finish(.success(())) + case .failed(let err): + finish(.failure(err)) + case .cancelled: + break + default: + break + } + } + conn.start(queue: queue) + queue.asyncAfter(deadline: .now() + timeout) { + finish(.failure(StoreError.message("timeout"))) + } + } + } + + private func udpProbe(host: String, port: UInt16) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let queue = DispatchQueue(label: "navis.ping.udp") + let conn = NWConnection( + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: port)!, + using: .udp + ) + var settled = false + let finish: (Result) -> Void = { result in + guard !settled else { return } + settled = true + conn.cancel() + cont.resume(with: result) + } + conn.stateUpdateHandler = { state in + switch state { + case .ready: + conn.send(content: Data([0]), completion: .contentProcessed { err in + if let err { + finish(.failure(err)) + } else { + finish(.success(())) + } + }) + case .failed(let err): + finish(.failure(err)) + default: + break + } + } + conn.start(queue: queue) + queue.asyncAfter(deadline: .now() + 1.2) { + finish(.failure(StoreError.message("timeout"))) + } + } + } +} + +enum StoreError: LocalizedError { + case message(String) + var errorDescription: String? { + switch self { + case .message(let s): return s + } + } +} diff --git a/ios/Shared/XrayConfigBuilder.swift b/ios/Shared/XrayConfigBuilder.swift new file mode 100644 index 0000000..4184d62 --- /dev/null +++ b/ios/Shared/XrayConfigBuilder.swift @@ -0,0 +1,400 @@ +import Foundation + +/// Mirrors internal/protocols/xray — share-link parse + SOCKS/HTTP config (ports 1080/1081). +enum XrayConfigBuilder { + struct Link { + var protocolKind: String // vless | vmess | trojan + var address: String + var port: Int + var uuid: String + var alterID: Int = 0 + var security: String = "" + var flow: String = "" + var network: String = "tcp" + var type: String = "" + var host: String = "" + var path: String = "" + var tls: String = "none" + var sni: String = "" + var alpn: String = "" + var fp: String = "" + var pbk: String = "" + var sid: String = "" + var spx: String = "" + var serviceName: String = "" + var mode: String = "" + var allowInsecure: Bool = false + var packetEncoding: String = "" + } + + static func buildJSON(uri: String, socksPort: Int = 1080, httpPort: Int = 1081) throws -> String { + let link = try parse(uri) + let outbound = try buildOutbound(link) + let root: [String: Any] = [ + "log": ["loglevel": "warning"], + "inbounds": [ + [ + "tag": "socks-in", + "listen": "127.0.0.1", + "port": socksPort, + "protocol": "socks", + "settings": ["udp": true, "auth": "noauth"], + ], + [ + "tag": "http-in", + "listen": "127.0.0.1", + "port": httpPort, + "protocol": "http", + ], + ], + "outbounds": [ + outbound, + ["protocol": "freedom", "tag": "direct"], + ["protocol": "blackhole", "tag": "block"], + ], + ] + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + guard let s = String(data: data, encoding: .utf8) else { + throw BuilderError.message("json encode failed") + } + return s + "\n" + } + + static func parse(_ raw: String) throws -> Link { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + if lower.hasPrefix("vless://") { return try parseVLESS(trimmed) } + if lower.hasPrefix("vmess://") { return try parseVMess(trimmed) } + if lower.hasPrefix("trojan://") { return try parseTrojan(trimmed) } + throw BuilderError.message("ожидалась ссылка vless://, vmess:// или trojan://") + } + + // MARK: - Outbound / stream (config_write.go) + + private static func buildOutbound(_ link: Link) throws -> [String: Any] { + let stream = try buildStreamSettings(link) + switch link.protocolKind { + case "vless": + var user: [String: Any] = [ + "id": link.uuid, + "encryption": firstNonEmpty(link.security, "none"), + ] + if !link.flow.isEmpty { user["flow"] = link.flow } + var settings: [String: Any] = [ + "vnext": [[ + "address": link.address, + "port": link.port, + "users": [user], + ]], + ] + if !link.packetEncoding.isEmpty { + settings["packetEncoding"] = link.packetEncoding + } + return [ + "tag": "proxy", + "protocol": "vless", + "settings": settings, + "streamSettings": stream, + ] + case "vmess": + return [ + "tag": "proxy", + "protocol": "vmess", + "settings": [ + "vnext": [[ + "address": link.address, + "port": link.port, + "users": [[ + "id": link.uuid, + "alterId": link.alterID, + "security": firstNonEmpty(link.security, "auto"), + ]], + ]], + ], + "streamSettings": stream, + ] + case "trojan": + return [ + "tag": "proxy", + "protocol": "trojan", + "settings": [ + "servers": [[ + "address": link.address, + "port": link.port, + "password": link.uuid, + ]], + ], + "streamSettings": stream, + ] + default: + throw BuilderError.message("unsupported xray protocol \(link.protocolKind)") + } + } + + private static func buildStreamSettings(_ link: Link) throws -> [String: Any] { + var network = firstNonEmpty(link.network, "tcp").lowercased() + var stream: [String: Any] = ["network": network] + + switch network { + case "ws", "websocket": + stream["network"] = "ws" + var ws: [String: Any] = [:] + if !link.path.isEmpty { ws["path"] = link.path } + if !link.host.isEmpty { ws["headers"] = ["Host": link.host] } + stream["wsSettings"] = ws + case "grpc", "gun": + stream["network"] = "grpc" + var grpc: [String: Any] = ["serviceName": firstNonEmpty(link.serviceName, link.path)] + if !link.mode.isEmpty { + grpc["multiMode"] = link.mode.lowercased() == "multi" + } + stream["grpcSettings"] = grpc + case "h2", "http": + stream["network"] = "h2" + var h2: [String: Any] = [:] + if !link.path.isEmpty { h2["path"] = link.path } + if !link.host.isEmpty { + h2["host"] = link.host.split(separator: ",").map { String($0) } + } + stream["httpSettings"] = h2 + case "httpupgrade": + stream["network"] = "httpupgrade" + var hu: [String: Any] = [:] + if !link.path.isEmpty { hu["path"] = link.path } + if !link.host.isEmpty { hu["host"] = link.host } + stream["httpupgradeSettings"] = hu + case "xhttp", "splithttp": + stream["network"] = "xhttp" + var xh: [String: Any] = [:] + if !link.path.isEmpty { xh["path"] = link.path } + if !link.host.isEmpty { xh["host"] = link.host } + if !link.mode.isEmpty { xh["mode"] = link.mode } + stream["xhttpSettings"] = xh + default: + stream["network"] = "tcp" + if !link.type.isEmpty && link.type != "none" { + stream["tcpSettings"] = ["header": ["type": link.type]] + } + } + + let sec = firstNonEmpty(link.tls, "none").lowercased() + switch sec { + case "", "none", "0", "false": + stream["security"] = "none" + case "tls": + stream["security"] = "tls" + var tls: [String: Any] = [ + "serverName": firstNonEmpty(link.sni, link.host, link.address), + "allowInsecure": link.allowInsecure, + ] + if !link.fp.isEmpty { tls["fingerprint"] = link.fp } + if !link.alpn.isEmpty { + tls["alpn"] = link.alpn.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + } + stream["tlsSettings"] = tls + case "reality": + if link.pbk.isEmpty { + throw BuilderError.message("reality: нужен pbk (publicKey)") + } + stream["security"] = "reality" + stream["realitySettings"] = [ + "serverName": firstNonEmpty(link.sni, link.host, link.address), + "fingerprint": firstNonEmpty(link.fp, "chrome"), + "publicKey": link.pbk, + "shortId": link.sid, + "spiderX": link.spx, + ] + default: + stream["security"] = sec + } + return stream + } + + // MARK: - Parsers + + private static func parseVLESS(_ raw: String) throws -> Link { + var remark = "" + var body = raw + if let hash = body.firstIndex(of: "#") { + remark = String(body[body.index(after: hash)...]).removingPercentEncoding ?? String(body[body.index(after: hash)...]) + body = String(body[.. Link { + var body = raw + if let hash = body.firstIndex(of: "#") { + body = String(body[.. Link { + var body = raw + if let r = body.range(of: "vmess://", options: .caseInsensitive) { + body = String(body[r.upperBound...]) + } + if let hash = body.firstIndex(of: "#") { + body = String(body[.. [String: String] { + var out: [String: String] = [:] + for item in u.queryItems ?? [] { + out[item.name] = item.value ?? "" + } + return out + } + + private static func firstNonEmpty(_ vals: String?...) -> String { + for v in vals { + if let v, !v.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return v.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return "" + } + + private static func truthy(_ s: String?) -> Bool { + switch (s ?? "").lowercased() { + case "1", "true", "yes", "y", "on": return true + default: return false + } + } + + private static func decodeBase64(_ s: String) -> String? { + var cleaned = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + let pad = (4 - cleaned.count % 4) % 4 + cleaned += String(repeating: "=", count: pad) + guard let data = Data(base64Encoded: cleaned) else { return nil } + return String(data: data, encoding: .utf8) + } +} + +enum BuilderError: LocalizedError { + case message(String) + var errorDescription: String? { + switch self { + case .message(let s): return s + } + } +} diff --git a/mobile/naviscore/hy2.go b/mobile/naviscore/hy2.go new file mode 100644 index 0000000..36a3065 --- /dev/null +++ b/mobile/naviscore/hy2.go @@ -0,0 +1,10 @@ +package naviscore + +import "errors" + +// Placeholder until unified gomobile NavisCore ships Xray+Hy2 in one Go runtime. +// The shipping iOS appex uses LibXray.xcframework for Xray; Hy2 cannot link a second Go core. +func startHysteria2(configPath string) (func() error, error) { + _ = configPath + return nil, errors.New("hysteria2: use unified NavisCore build (conflicts with LibXray Go runtime)") +} diff --git a/mobile/naviscore/naviscore.go b/mobile/naviscore/naviscore.go new file mode 100644 index 0000000..03729e7 --- /dev/null +++ b/mobile/naviscore/naviscore.go @@ -0,0 +1,151 @@ +// Package naviscore is a gomobile-friendly wrapper that runs Xray or Hysteria2 +// in-process (macOS Manager semantics: local SOCKS/HTTP listeners). +// +// Build: +// +// gomobile bind -target=ios,iossimulator -o ios/Vendor/NavisCore.xcframework ./mobile/naviscore +package naviscore + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "time" + + libxrayxray "github.com/xtls/libxray/xray" +) + +var ( + mu sync.Mutex + mode string // "xray" | "hy2" | "" + hy2Stop func() error + workDir string +) + +// SetWorkDir sets a writable directory for runtime config files (extension container). +func SetWorkDir(dir string) { + mu.Lock() + defer mu.Unlock() + workDir = dir +} + +// StartXrayJSON runs Xray from a full config JSON string (SOCKS/HTTP inbounds included). +func StartXrayJSON(configJSON string) error { + mu.Lock() + defer mu.Unlock() + if mode != "" { + return errors.New("core already running") + } + if err := libxrayxray.RunXrayFromJSON(configJSON); err != nil { + return err + } + mode = "xray" + return nil +} + +// StartXrayFile runs Xray from a config.json path. +func StartXrayFile(configPath string) error { + mu.Lock() + defer mu.Unlock() + if mode != "" { + return errors.New("core already running") + } + if err := libxrayxray.RunXray(configPath); err != nil { + return err + } + mode = "xray" + return nil +} + +// StartHy2YAML writes YAML to workDir and starts the Hysteria2 client library. +// If the hysteria embed is unavailable in this build, returns a clear error. +func StartHy2YAML(configYAML string) error { + mu.Lock() + defer mu.Unlock() + if mode != "" { + return errors.New("core already running") + } + dir := workDir + if dir == "" { + dir = os.TempDir() + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + path := filepath.Join(dir, "hy2.yaml") + if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil { + return err + } + stop, err := startHysteria2(path) + if err != nil { + return err + } + hy2Stop = stop + mode = "hy2" + return nil +} + +// Stop stops whichever core is running. +func Stop() error { + mu.Lock() + defer mu.Unlock() + var err error + switch mode { + case "xray": + err = libxrayxray.StopXray() + case "hy2": + if hy2Stop != nil { + err = hy2Stop() + hy2Stop = nil + } + } + mode = "" + return err +} + +// IsRunning reports whether a core is active. +func IsRunning() bool { + mu.Lock() + defer mu.Unlock() + switch mode { + case "xray": + return libxrayxray.GetXrayState() + case "hy2": + return hy2Stop != nil + default: + return false + } +} + +// WaitPort waits until TCP 127.0.0.1:port accepts connections. +func WaitPort(port int, timeoutMs int) error { + if port <= 0 { + return fmt.Errorf("bad port") + } + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + addr := net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", port)) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = c.Close() + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("локальный прокси :%d не поднялся", port) +} + +// Version returns a short build label. +func Version() string { + return "naviscore/xray+hy2" +} + +// PingJSON is a tiny helper so Swift can verify the framework linked. +func PingJSON() string { + b, _ := json.Marshal(map[string]any{"ok": true, "version": Version()}) + return string(b) +} diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 0000000..203b010 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Build Navis iOS device IPA (+ optional simulator zip). +# +# Usage: +# ./scripts/build-ios.sh +# # device IPA (unsigned if no Team) + simulator zip +# NAVIS_IOS_TEAM_ID=XXXXXXXXXX ./scripts/build-ios.sh +# # signed archive + exported IPA +# NAVIS_IOS_SKIP_SIMULATOR=1 ./scripts/build-ios.sh +# # device only +# +# Optional: +# NAVIS_IOS_SIGN_IDENTITY="Apple Development: …" +# NAVIS_IOS_EXPORT_METHOD=development|ad-hoc|enterprise|app-store +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IOS="$ROOT/ios" +OUT="$ROOT/dist/navis-release/ios" +PROJECT="$IOS/Navis.xcodeproj" +SCHEME=Navis +CONFIG=Release + +# Keep VERSION_FULL in sync with internal/update/update.go (also via sync-version.py). +VERSION_FULL="$( + python3 - <&2 + exit 1 +} +echo "==> Navis iOS build $VERSION_FULL" + +# Ensure Info.plist stays aligned with update.go. +python3 "$ROOT/scripts/sync-version.py" + +# Ensure LibXray + Hev are present +"$ROOT/scripts/fetch-ios-cores.sh" + +mkdir -p "$OUT" +find "$OUT" -mindepth 1 -maxdepth 1 ! -name '.gitkeep' -exec rm -rf {} + + +TEAM="${NAVIS_IOS_TEAM_ID:-}" +EXPORT_METHOD="${NAVIS_IOS_EXPORT_METHOD:-development}" +DERIVED="${TMPDIR:-/tmp}/navis-ios-derived" +rm -rf "$DERIVED" +mkdir -p "$DERIVED" + +package_ipa() { + local app_path="$1" + local ipa_path="$2" + local stage + stage="$(mktemp -d)" + mkdir -p "$stage/Payload" + ditto "$app_path" "$stage/Payload/Navis.app" + ( + cd "$stage" + zip -qry "$ipa_path" Payload + ) + rm -rf "$stage" +} + +# --- Device (iphoneos) --- +echo "==> Building iOS device ($CONFIG, sdk=iphoneos)" +DEV_ARGS=( + -project "$PROJECT" + -scheme "$SCHEME" + -configuration "$CONFIG" + -destination "generic/platform=iOS" + -derivedDataPath "$DERIVED" + -sdk iphoneos + ONLY_ACTIVE_ARCH=NO +) +if [[ -n "$TEAM" ]]; then + DEV_ARGS+=(DEVELOPMENT_TEAM="$TEAM") + if [[ -n "${NAVIS_IOS_SIGN_IDENTITY:-}" ]]; then + DEV_ARGS+=(CODE_SIGN_IDENTITY="$NAVIS_IOS_SIGN_IDENTITY") + fi + echo "==> Archiving (Team=$TEAM)" + ARCHIVE="$OUT/Navis.xcarchive" + xcodebuild "${DEV_ARGS[@]}" -archivePath "$ARCHIVE" archive + + EXPORT_PLIST="$OUT/ExportOptions.plist" + cat >"$EXPORT_PLIST" < + + + + method + ${EXPORT_METHOD} + teamID + ${TEAM} + signingStyle + automatic + compileBitcode + + stripSwiftSymbols + + + +EOF + + EXPORT_DIR="$OUT/export" + rm -rf "$EXPORT_DIR" + mkdir -p "$EXPORT_DIR" + echo "==> Exporting signed IPA (method=$EXPORT_METHOD)" + xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportPath "$EXPORT_DIR" \ + -exportOptionsPlist "$EXPORT_PLIST" + + IPA="$(find "$EXPORT_DIR" -name '*.ipa' | head -n1 || true)" + if [[ -n "${IPA:-}" ]]; then + cp "$IPA" "$OUT/Navis-${VERSION_FULL}.ipa" + cp "$IPA" "$OUT/Navis.ipa" + echo "wrote $OUT/Navis-${VERSION_FULL}.ipa (signed)" + else + echo "error: signed export produced no .ipa" >&2 + ls -la "$EXPORT_DIR" || true + exit 1 + fi +else + DEV_ARGS+=( + CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_REQUIRED=NO + CODE_SIGN_IDENTITY=- + EXPAND_SDK_VARIABLES=YES + ) + xcodebuild "${DEV_ARGS[@]}" build + + DEV_APP="$(find "$DERIVED/Build/Products" -path '*iphoneos/Navis.app' -type d | head -n1 || true)" + if [[ -z "${DEV_APP:-}" || ! -d "$DEV_APP" ]]; then + echo "error: device Navis.app not found under $DERIVED" >&2 + find "$DERIVED/Build/Products" -name 'Navis.app' -type d 2>/dev/null || true + exit 1 + fi + + cp -R "$DEV_APP" "$OUT/Navis-device.app" + package_ipa "$DEV_APP" "$OUT/Navis-${VERSION_FULL}.ipa" + cp "$OUT/Navis-${VERSION_FULL}.ipa" "$OUT/Navis.ipa" + echo "wrote $OUT/Navis-device.app" + echo "wrote $OUT/Navis-${VERSION_FULL}.ipa (UNSIGNED — re-sign before install)" + echo "wrote $OUT/Navis.ipa" +fi + +# --- Simulator (optional) --- +if [[ "${NAVIS_IOS_SKIP_SIMULATOR:-}" != "1" ]]; then + echo "==> Building iOS Simulator ($CONFIG)" + SIM_ARGS=( + -project "$PROJECT" + -scheme "$SCHEME" + -configuration "$CONFIG" + -destination "generic/platform=iOS Simulator" + -derivedDataPath "$DERIVED" + ONLY_ACTIVE_ARCH=NO + ) + if [[ -n "$TEAM" ]]; then + SIM_ARGS+=(DEVELOPMENT_TEAM="$TEAM") + else + SIM_ARGS+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO) + fi + xcodebuild "${SIM_ARGS[@]}" build + + SIM_APP="$(find "$DERIVED/Build/Products" -path '*iphonesimulator/Navis.app' -type d | head -n1 || true)" + if [[ -n "${SIM_APP:-}" && -d "$SIM_APP" ]]; then + cp -R "$SIM_APP" "$OUT/Navis-simulator.app" + ( + cd "$OUT" + ditto -c -k --sequesterRsrc --keepParent Navis-simulator.app "Navis-${VERSION_FULL}-simulator.app.zip" + ) + echo "wrote $OUT/Navis-simulator.app" + fi +fi + +echo "done → $OUT" +ls -lh "$OUT"/*.ipa "$OUT"/*.app 2>/dev/null || ls -lh "$OUT" diff --git a/scripts/bump-build.py b/scripts/bump-build.py index eb648a3..a8a03a9 100644 --- a/scripts/bump-build.py +++ b/scripts/bump-build.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Increment BuildNumber in update.go, then sync versioninfo / macOS bat / Android.""" +"""Increment BuildNumber in update.go, then sync versioninfo / macOS bat / Android / iOS.""" from __future__ import annotations import re diff --git a/scripts/fetch-ios-cores.sh b/scripts/fetch-ios-cores.sh new file mode 100755 index 0000000..44cbce7 --- /dev/null +++ b/scripts/fetch-ios-cores.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Fetch prebuilt LibXray + HevSocks5Tunnel into ios/Vendor. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VENDOR="$ROOT/ios/Vendor" +mkdir -p "$VENDOR" +cd "$VENDOR" + +LIBXRAY_VER="${NAVIS_LIBXRAY_VERSION:-26.7.28}" +HEV_VER="${NAVIS_HEV_VERSION:-2.10.0}" + +if [[ ! -d LibXray.xcframework ]]; then + echo "==> LibXray $LIBXRAY_VER" + curl -fsSL -o LibXray.xcframework.zip \ + "https://github.com/wanliyunyan/LibXray/releases/download/${LIBXRAY_VER}/LibXray.xcframework.zip" + unzip -qo LibXray.xcframework.zip + rm -f LibXray.xcframework.zip +else + echo "LibXray.xcframework present" +fi + +if [[ ! -d HevSocks5Tunnel.xcframework ]]; then + echo "==> HevSocks5Tunnel $HEV_VER" + curl -fsSL -o HevSocks5Tunnel.xcframework.zip \ + "https://github.com/wanliyunyan/HevSocks5Tunnel/releases/download/${HEV_VER}/HevSocks5Tunnel.xcframework.zip" + unzip -qo HevSocks5Tunnel.xcframework.zip + rm -f HevSocks5Tunnel.xcframework.zip +else + echo "HevSocks5Tunnel.xcframework present" +fi + +echo "Vendor ready:" +ls -d *.xcframework diff --git a/scripts/sync-version.py b/scripts/sync-version.py index 4da909f..efd2126 100755 --- a/scripts/sync-version.py +++ b/scripts/sync-version.py @@ -12,6 +12,11 @@ VERSIONINFO = ROOT / "versioninfo.json" BUILD_BAT = ROOT / "build-macos.bat" ANDROID_GRADLE = ROOT / "android" / "app" / "build.gradle.kts" INDEX_HTML = ROOT / "internal" / "appui" / "index.html" +IOS_README = ROOT / "ios" / "README.md" +IOS_PLISTS = ( + ROOT / "ios" / "Navis" / "Info.plist", + ROOT / "ios" / "NavisTunnel" / "Info.plist", +) def read_consts() -> tuple[str, int]: @@ -77,6 +82,42 @@ def main() -> None: else: print("index.html badge already in sync") + for plist in IOS_PLISTS: + if not plist.exists(): + continue + p = plist.read_text(encoding="utf-8") + p2 = re.sub( + r"(CFBundleShortVersionString\s*)[^<]+", + rf"\g<1>{ver}", + p, + count=1, + ) + p2 = re.sub( + r"(CFBundleVersion\s*)[^<]+", + rf"\g<1>{full}", + p2, + count=1, + ) + if p2 != p: + plist.write_text(p2, encoding="utf-8") + print("updated", plist, "->", ver, full) + else: + print(plist.name, "already in sync") + + if IOS_README.exists(): + r = IOS_README.read_text(encoding="utf-8") + r2 = re.sub( + r"(Версия:\s*\*\*)[^*]+(\*\*)", + rf"\g<1>{display}\2", + r, + count=1, + ) + if r2 != r: + IOS_README.write_text(r2, encoding="utf-8") + print("updated", IOS_README, "->", display) + else: + print("ios/README.md already in sync") + if __name__ == "__main__": main() diff --git a/server/update.json b/server/update.json index 5e51212..25846d3 100644 --- a/server/update.json +++ b/server/update.json @@ -1,6 +1,6 @@ { "version": "3.8.2", - "notes": "Navis 3.8.2+12: экспорт и импорт всего списка серверов в JSON-файл (кнопки после «Лучший»); импорт заменяет текущий список; системный диалог выбора файла.", + "notes": "Navis 3.8.2+13: iOS Packet Tunnel IPA (VLESS/VMess/Trojan + LibXray/hev); Clash Meta URL-подписки; выбор сервера без подмены; desktop — полный Clash→share (vless/trojan/hy2).", "platform": "windows-amd64", "os": "windows", "arch": "amd64", diff --git a/versioninfo.json b/versioninfo.json index 5a2263c..9c2f766 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -4,13 +4,13 @@ "Major": 3, "Minor": 8, "Patch": 2, - "Build": 12 + "Build": 13 }, "ProductVersion": { "Major": 3, "Minor": 8, "Patch": 2, - "Build": 12 + "Build": 13 }, "FileFlagsMask": "3f", "FileFlags": "00", @@ -21,12 +21,12 @@ "StringFileInfo": { "CompanyName": "EvilFox", "FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", - "FileVersion": "3.8.2.12", + "FileVersion": "3.8.2.13", "InternalName": "Navis", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "3.8.2.12", + "ProductVersion": "3.8.2.13", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {