Release 3.8.2.13: iOS Packet Tunnel client and Clash URL subscriptions.
Add SwiftUI + Network Extension (LibXray/hev), parse Clash Meta YAML from subscription URLs into vless/trojan/hy2 share links, keep the selected server on connect, and ship Navis-3.8.2.13.ipa with versioned changelog. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -262,7 +262,8 @@ https://evilfox.win/
|
|||||||
- 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»;
|
- 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+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив;
|
||||||
- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей;
|
- 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:
|
В 3.8.1:
|
||||||
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
|
||||||
versionCode = 3080212
|
versionCode = 3080213
|
||||||
versionName = "3.8.2+12"
|
versionName = "3.8.2+13"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||||
|
|||||||
+3
-3
@@ -42,11 +42,11 @@ if errorlevel 1 exit /b 1
|
|||||||
go build -o "tools\packmac\packmac.exe" .\tools\packmac
|
go build -o "tools\packmac\packmac.exe" .\tools\packmac
|
||||||
if errorlevel 1 exit /b 1
|
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
|
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
|
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
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
echo Built Mac GUI + CLI:
|
echo Built Mac GUI + CLI:
|
||||||
|
|||||||
BIN
Binary file not shown.
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"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",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"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",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ func buildStreamSettings(link Link) (map[string]any, error) {
|
|||||||
if link.Host != "" {
|
if link.Host != "" {
|
||||||
xh["host"] = link.Host
|
xh["host"] = link.Host
|
||||||
}
|
}
|
||||||
|
if link.Mode != "" {
|
||||||
|
xh["mode"] = link.Mode
|
||||||
|
}
|
||||||
stream["xhttpSettings"] = xh
|
stream["xhttpSettings"] = xh
|
||||||
default: // tcp
|
default: // tcp
|
||||||
stream["network"] = "tcp"
|
stream["network"] = "tcp"
|
||||||
|
|||||||
+349
-51
@@ -95,7 +95,7 @@ func ParseBody(body string) ([]Item, error) {
|
|||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок")
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseText(text string) ([]Item, error) {
|
func parseText(text string) ([]Item, error) {
|
||||||
@@ -105,7 +105,7 @@ func parseText(text string) ([]Item, error) {
|
|||||||
for _, m := range extracted {
|
for _, m := range extracted {
|
||||||
lines = append(lines, strings.TrimRight(m, ".,;)]}\"'"))
|
lines = append(lines, strings.TrimRight(m, ".,;)]}\"'"))
|
||||||
}
|
}
|
||||||
for _, block := range extractClashHY2(text) {
|
for _, block := range extractClashProxies(text) {
|
||||||
lines = append(lines, block)
|
lines = append(lines, block)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +123,7 @@ func parseText(text string) ([]Item, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip bare https:// without credentials (common noise in HTML/YAML).
|
// 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+") {
|
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -149,7 +150,7 @@ func parseText(text string) ([]Item, error) {
|
|||||||
out = append(out, Item{Name: name, Protocol: detected, URI: normalized})
|
out = append(out, Item{Name: name, Protocol: detected, URI: normalized})
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)")
|
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок")
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -159,70 +160,321 @@ func naiveLinkHasAuth(line string) bool {
|
|||||||
for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} {
|
for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} {
|
||||||
if strings.HasPrefix(lower, p) {
|
if strings.HasPrefix(lower, p) {
|
||||||
rest := line[len(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 at > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractClashHY2(text string) []string {
|
var reClashProxyName = regexp.MustCompile(`(?m)^[ \t]*-\s+name\s*:\s*`)
|
||||||
// Very small helper for Clash YAML hysteria2 blocks when share URI is absent.
|
|
||||||
|
// 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)
|
lower := strings.ToLower(text)
|
||||||
if !strings.Contains(lower, "type:") || !strings.Contains(lower, "hysteria2") {
|
if !strings.Contains(lower, "type:") {
|
||||||
return nil
|
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
|
var out []string
|
||||||
// Split roughly by list items.
|
for _, chunk := range splitClashProxyBlocks(scope) {
|
||||||
chunks := strings.Split(text, "\n- ")
|
if link := clashBlockToShareURI(chunk); link != "" {
|
||||||
for _, chunk := range chunks {
|
out = append(out, link)
|
||||||
cl := strings.ToLower(chunk)
|
|
||||||
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
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
|
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 {
|
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)
|
m := re.FindStringSubmatch(block)
|
||||||
if len(m) < 2 {
|
if len(m) < 2 {
|
||||||
return ""
|
return ""
|
||||||
@@ -230,6 +482,52 @@ func yamlField(block, key string) string {
|
|||||||
return strings.TrimSpace(m[1])
|
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) {
|
func decodeMaybeBase64(s string) (string, error) {
|
||||||
s = strings.Map(func(r rune) rune {
|
s = strings.Map(func(r rune) rune {
|
||||||
if unicode.IsSpace(r) {
|
if unicode.IsSpace(r) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package subscription
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -43,18 +45,100 @@ proxies:
|
|||||||
sni: de.example.com
|
sni: de.example.com
|
||||||
obfs: salamander
|
obfs: salamander
|
||||||
obfs-password: obfspass
|
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)
|
items, err := ParseBody(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(items) != 1 {
|
if len(items) != 3 {
|
||||||
t.Fatalf("got %d", len(items))
|
t.Fatalf("got %d items: %+v", len(items), items)
|
||||||
}
|
}
|
||||||
if items[0].Protocol != "hysteria2" {
|
byProto := map[string]int{}
|
||||||
t.Fatalf("proto %s", items[0].Protocol)
|
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") {
|
if byProto["hysteria2"] != 1 || byProto["vless"] != 1 || byProto["trojan"] != 1 {
|
||||||
t.Fatalf("uri %s", items[0].URI)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
|
|||||||
|
|
||||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
// 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).
|
// 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"
|
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# iOS build artifacts / vendored cores (fetched by scripts/fetch-ios-cores.sh)
|
||||||
|
Vendor/*.xcframework/
|
||||||
|
Vendor/*.zip
|
||||||
@@ -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 = "<group>"; };
|
||||||
|
A7F938564D384446A8F846EE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
F4D1813E91824E3F84A8215A /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||||
|
6ABE496B5F8C4989A0255C39 /* VPNManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNManager.swift; sourceTree = "<group>"; };
|
||||||
|
96E3096272794C55A39F63DE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
C16CF76BAE864AC1AAF3FA1E /* Navis.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Navis.entitlements; sourceTree = "<group>"; };
|
||||||
|
FC28394BB0E342E3B3545269 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
8B72E16D41D54F6387EFB252 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
|
||||||
|
BAB193EB4D4C4514A7173D5D /* LibXrayCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibXrayCore.swift; sourceTree = "<group>"; };
|
||||||
|
BB7C3869B0494261AC7A120B /* HevBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HevBridge.swift; sourceTree = "<group>"; };
|
||||||
|
765826485F7C4B62B380EEAB /* TunFileDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunFileDescriptor.swift; sourceTree = "<group>"; };
|
||||||
|
8792AB00E67748C1AFC6C4E2 /* Hy2CoreRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hy2CoreRunner.swift; sourceTree = "<group>"; };
|
||||||
|
D5BB903F8B1240ED8679CF53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
9B1C066864904580ABAEE9A2 /* NavisTunnel.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NavisTunnel.entitlements; sourceTree = "<group>"; };
|
||||||
|
78875E829DBA4B06BE893CC9 /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = "<group>"; };
|
||||||
|
ED2CB1D931A94BC1935E05AD /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||||
|
58367830ACE545ABB8911A29 /* LinkParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkParser.swift; sourceTree = "<group>"; };
|
||||||
|
D925646CEFA349C491515E42 /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = "<group>"; };
|
||||||
|
3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XrayConfigBuilder.swift; sourceTree = "<group>"; };
|
||||||
|
C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hy2ConfigBuilder.swift; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
B77B1677E4FC4E26B5E426CF /* HevSocks5Tunnel.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = HevSocks5Tunnel.xcframework; sourceTree = "<group>"; };
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
A7ABA672EF12436BBA8E987F /* Vendor */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D56C90149F6E4CD2BF7A8212 /* LibXray.xcframework */,
|
||||||
|
B77B1677E4FC4E26B5E426CF /* HevSocks5Tunnel.xcframework */,
|
||||||
|
);
|
||||||
|
path = Vendor;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A11DCC2FCC0F4FD78C684784 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */,
|
||||||
|
23490141FD0E4F8898217B74 /* Network.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
6C7E4BA41F5F41AA82BB0592 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C4C0B8F1FA9C4BACA4ACBF65 /* Navis.app */,
|
||||||
|
DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "1AFC19A4F55C418C868BE1EF"
|
||||||
|
BuildableName = "Navis.app"
|
||||||
|
BlueprintName = "Navis"
|
||||||
|
ReferencedContainer = "container:Navis.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "1AFC19A4F55C418C868BE1EF"
|
||||||
|
BuildableName = "Navis.app"
|
||||||
|
BlueprintName = "Navis"
|
||||||
|
ReferencedContainer = "container:Navis.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "1AFC19A4F55C418C868BE1EF"
|
||||||
|
BuildableName = "Navis.app"
|
||||||
|
BlueprintName = "Navis"
|
||||||
|
ReferencedContainer = "container:Navis.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -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("Отключено")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Navis</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>3.8.2</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>3.8.2.13</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
|
<array>
|
||||||
|
<string>packet-tunnel-provider</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.win.evilfox.navis</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>NavisTunnel</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>3.8.2</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>3.8.2.13</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.networkextension.packet-tunnel</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
|
<array>
|
||||||
|
<string>packet-tunnel-provider</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.win.evilfox.navis</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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<sockaddr_in>.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<sockaddr_in>.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<addrinfo>?
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-<version>.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)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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[..<hash])
|
||||||
|
}
|
||||||
|
let lower = s.lowercased()
|
||||||
|
if lower.hasPrefix("hy2://") {
|
||||||
|
s = "hysteria2://" + String(s.dropFirst("hy2://".count))
|
||||||
|
}
|
||||||
|
guard lower.hasPrefix("hysteria2://") || s.lowercased().hasPrefix("hysteria2://") else {
|
||||||
|
throw BuilderError.message("ожидалась ссылка hysteria2:// или hy2://")
|
||||||
|
}
|
||||||
|
guard let u = URLComponents(string: s) else {
|
||||||
|
throw BuilderError.message("parse hysteria2 URI")
|
||||||
|
}
|
||||||
|
guard let host = u.host, !host.isEmpty else {
|
||||||
|
throw BuilderError.message("hysteria2 URI missing host")
|
||||||
|
}
|
||||||
|
var server = host
|
||||||
|
if let port = u.port {
|
||||||
|
server = "\(host):\(port)"
|
||||||
|
} else if let h = u.percentEncodedHost ?? u.host {
|
||||||
|
// URLComponents may put multi-port in host; prefer raw host field from string.
|
||||||
|
if let at = s.range(of: "://") {
|
||||||
|
var rest = String(s[at.upperBound...])
|
||||||
|
if let slash = rest.firstIndex(of: "/") { rest = String(rest[..<slash]) }
|
||||||
|
if let q = rest.firstIndex(of: "?") { rest = String(rest[..<q]) }
|
||||||
|
if let atSign = rest.firstIndex(of: "@") {
|
||||||
|
rest = String(rest[rest.index(after: atSign)...])
|
||||||
|
}
|
||||||
|
if !rest.isEmpty { server = rest }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var auth = ""
|
||||||
|
if let user = u.user {
|
||||||
|
if let pass = u.password {
|
||||||
|
auth = "\(user):\(pass)"
|
||||||
|
} else {
|
||||||
|
auth = user.removingPercentEncoding ?? user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let q = Dictionary(uniqueKeysWithValues: (u.queryItems ?? []).map { ($0.name, $0.value ?? "") })
|
||||||
|
var ep = Endpoint(server: server, auth: auth)
|
||||||
|
ep.sni = first(q, "sni", "peer")
|
||||||
|
ep.pinSHA256 = first(q, "pinSHA256", "pinsha256")
|
||||||
|
ep.obfs = first(q, "obfs")
|
||||||
|
ep.obfsPassword = first(q, "obfs-password", "obfs_password")
|
||||||
|
let insc = first(q, "insecure")
|
||||||
|
ep.insecure = insc == "1" || insc.lowercased() == "true"
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func first(_ q: [String: String], _ keys: String...) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>()
|
||||||
|
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..<end]))
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func clashBlockToShareURI(_ block: String) -> 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[..<endR.lowerBound])
|
||||||
|
}
|
||||||
|
return String(after)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func yamlSection(_ block: String, _ key: String) -> 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[..<hash]).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
if !v.isEmpty { items.append(v) }
|
||||||
|
}
|
||||||
|
return items.joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstNonEmpty(_ vals: String...) -> 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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Void, Error>) 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, Error>) -> 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<Void, Error>) 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, Error>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[..<hash])
|
||||||
|
}
|
||||||
|
guard let u = URLComponents(string: body) else {
|
||||||
|
throw BuilderError.message("parse vless failed")
|
||||||
|
}
|
||||||
|
let host = u.host ?? ""
|
||||||
|
let port = u.port ?? 443
|
||||||
|
let uuid = u.user ?? ""
|
||||||
|
if host.isEmpty || uuid.isEmpty {
|
||||||
|
throw BuilderError.message("vless: нужен uuid@host:port")
|
||||||
|
}
|
||||||
|
let q = queryMap(u)
|
||||||
|
var link = Link(
|
||||||
|
protocolKind: "vless",
|
||||||
|
address: host,
|
||||||
|
port: port,
|
||||||
|
uuid: uuid,
|
||||||
|
security: firstNonEmpty(q["encryption"], "none"),
|
||||||
|
flow: q["flow"] ?? "",
|
||||||
|
network: firstNonEmpty(q["type"], q["network"], "tcp"),
|
||||||
|
type: q["headerType"] ?? "",
|
||||||
|
host: firstNonEmpty(q["host"], q["authority"]),
|
||||||
|
path: firstNonEmpty(q["path"], q["serviceName"]),
|
||||||
|
tls: firstNonEmpty(q["security"], "none"),
|
||||||
|
sni: firstNonEmpty(q["sni"], q["serverName"]),
|
||||||
|
alpn: q["alpn"] ?? "",
|
||||||
|
fp: firstNonEmpty(q["fp"], q["fingerprint"]),
|
||||||
|
pbk: firstNonEmpty(q["pbk"], q["publicKey"]),
|
||||||
|
sid: firstNonEmpty(q["sid"], q["shortId"]),
|
||||||
|
spx: firstNonEmpty(q["spx"], q["spiderX"]),
|
||||||
|
serviceName: firstNonEmpty(q["serviceName"], q["path"]),
|
||||||
|
mode: q["mode"] ?? "",
|
||||||
|
allowInsecure: truthy(q["allowInsecure"]) || truthy(q["insecure"]),
|
||||||
|
packetEncoding: q["packetEncoding"] ?? ""
|
||||||
|
)
|
||||||
|
_ = remark
|
||||||
|
if link.network == "grpc" && link.serviceName.isEmpty {
|
||||||
|
link.serviceName = link.path
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseTrojan(_ raw: String) throws -> Link {
|
||||||
|
var body = raw
|
||||||
|
if let hash = body.firstIndex(of: "#") {
|
||||||
|
body = String(body[..<hash])
|
||||||
|
}
|
||||||
|
guard let u = URLComponents(string: body) else {
|
||||||
|
throw BuilderError.message("parse trojan failed")
|
||||||
|
}
|
||||||
|
let host = u.host ?? ""
|
||||||
|
let port = u.port ?? 443
|
||||||
|
var password = u.user ?? ""
|
||||||
|
if let p = u.password, !p.isEmpty {
|
||||||
|
password = password + ":" + p
|
||||||
|
}
|
||||||
|
if host.isEmpty || password.isEmpty {
|
||||||
|
throw BuilderError.message("trojan: нужен password@host:port")
|
||||||
|
}
|
||||||
|
let q = queryMap(u)
|
||||||
|
var tls = firstNonEmpty(q["security"], "tls")
|
||||||
|
if tls.isEmpty || tls == "none" { tls = "tls" }
|
||||||
|
return Link(
|
||||||
|
protocolKind: "trojan",
|
||||||
|
address: host,
|
||||||
|
port: port,
|
||||||
|
uuid: password,
|
||||||
|
network: firstNonEmpty(q["type"], q["network"], "tcp"),
|
||||||
|
type: q["headerType"] ?? "",
|
||||||
|
host: firstNonEmpty(q["host"], q["authority"]),
|
||||||
|
path: q["path"] ?? "",
|
||||||
|
tls: tls,
|
||||||
|
sni: firstNonEmpty(q["sni"], q["peer"], q["serverName"]),
|
||||||
|
alpn: q["alpn"] ?? "",
|
||||||
|
fp: firstNonEmpty(q["fp"], q["fingerprint"]),
|
||||||
|
pbk: firstNonEmpty(q["pbk"], q["publicKey"]),
|
||||||
|
sid: firstNonEmpty(q["sid"], q["shortId"]),
|
||||||
|
spx: firstNonEmpty(q["spx"], q["spiderX"]),
|
||||||
|
serviceName: firstNonEmpty(q["serviceName"], q["path"]),
|
||||||
|
mode: q["mode"] ?? "",
|
||||||
|
allowInsecure: truthy(q["allowInsecure"]) || truthy(q["insecure"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseVMess(_ raw: String) throws -> 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[..<hash])
|
||||||
|
}
|
||||||
|
body = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let decoded = decodeBase64(body),
|
||||||
|
let data = decoded.data(using: .utf8),
|
||||||
|
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else {
|
||||||
|
throw BuilderError.message("vmess base64/json")
|
||||||
|
}
|
||||||
|
let add = obj["add"] as? String ?? ""
|
||||||
|
let id = obj["id"] as? String ?? ""
|
||||||
|
if add.isEmpty || id.isEmpty {
|
||||||
|
throw BuilderError.message("vmess: нет add/id")
|
||||||
|
}
|
||||||
|
var tls = (obj["tls"] as? String ?? "").lowercased()
|
||||||
|
if tls == "1" || tls == "true" { tls = "tls" }
|
||||||
|
if tls.isEmpty { tls = "none" }
|
||||||
|
let port: Int = {
|
||||||
|
if let i = obj["port"] as? Int { return i }
|
||||||
|
if let s = obj["port"] as? String, let i = Int(s) { return i }
|
||||||
|
if let d = obj["port"] as? Double { return Int(d) }
|
||||||
|
return 443
|
||||||
|
}()
|
||||||
|
let aid: Int = {
|
||||||
|
if let i = obj["aid"] as? Int { return i }
|
||||||
|
if let s = obj["aid"] as? String, let i = Int(s) { return i }
|
||||||
|
if let d = obj["aid"] as? Double { return Int(d) }
|
||||||
|
return 0
|
||||||
|
}()
|
||||||
|
let host = obj["host"] as? String ?? ""
|
||||||
|
return Link(
|
||||||
|
protocolKind: "vmess",
|
||||||
|
address: add,
|
||||||
|
port: port,
|
||||||
|
uuid: id,
|
||||||
|
alterID: aid,
|
||||||
|
security: firstNonEmpty(obj["scy"] as? String, "auto"),
|
||||||
|
network: firstNonEmpty(obj["net"] as? String, "tcp"),
|
||||||
|
type: obj["type"] as? String ?? "",
|
||||||
|
host: host,
|
||||||
|
path: obj["path"] as? String ?? "",
|
||||||
|
tls: tls,
|
||||||
|
sni: firstNonEmpty(obj["sni"] as? String, host),
|
||||||
|
alpn: obj["alpn"] as? String ?? "",
|
||||||
|
fp: obj["fp"] as? String ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private static func queryMap(_ u: URLComponents) -> [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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Executable
+184
@@ -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 - <<PY
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
text = Path(r"$ROOT/internal/update/update.go").read_text(encoding="utf-8")
|
||||||
|
ver = re.search(r'CurrentVersion\s*=\s*"([^"]+)"', text).group(1)
|
||||||
|
build = re.search(r"const BuildNumber\s*=\s*(\d+)", text).group(1)
|
||||||
|
print(f"{ver}.{build}")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
# Fail early if parsing broke.
|
||||||
|
[[ "$VERSION_FULL" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||||
|
echo "failed to parse version from update.go" >&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" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>${EXPORT_METHOD}</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>${TEAM}</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>compileBitcode</key>
|
||||||
|
<false/>
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|||||||
Executable
+33
@@ -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
|
||||||
@@ -12,6 +12,11 @@ VERSIONINFO = ROOT / "versioninfo.json"
|
|||||||
BUILD_BAT = ROOT / "build-macos.bat"
|
BUILD_BAT = ROOT / "build-macos.bat"
|
||||||
ANDROID_GRADLE = ROOT / "android" / "app" / "build.gradle.kts"
|
ANDROID_GRADLE = ROOT / "android" / "app" / "build.gradle.kts"
|
||||||
INDEX_HTML = ROOT / "internal" / "appui" / "index.html"
|
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]:
|
def read_consts() -> tuple[str, int]:
|
||||||
@@ -77,6 +82,42 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
print("index.html badge already in sync")
|
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"(<key>CFBundleShortVersionString</key>\s*<string>)[^<]+",
|
||||||
|
rf"\g<1>{ver}",
|
||||||
|
p,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
p2 = re.sub(
|
||||||
|
r"(<key>CFBundleVersion</key>\s*<string>)[^<]+",
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"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",
|
"platform": "windows-amd64",
|
||||||
"os": "windows",
|
"os": "windows",
|
||||||
"arch": "amd64",
|
"arch": "amd64",
|
||||||
|
|||||||
+4
-4
@@ -4,13 +4,13 @@
|
|||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 2,
|
"Patch": 2,
|
||||||
"Build": 12
|
"Build": 13
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 3,
|
"Major": 3,
|
||||||
"Minor": 8,
|
"Minor": 8,
|
||||||
"Patch": 2,
|
"Patch": 2,
|
||||||
"Build": 12
|
"Build": 13
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
"FileFlags": "00",
|
"FileFlags": "00",
|
||||||
@@ -21,12 +21,12 @@
|
|||||||
"StringFileInfo": {
|
"StringFileInfo": {
|
||||||
"CompanyName": "EvilFox",
|
"CompanyName": "EvilFox",
|
||||||
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
|
||||||
"FileVersion": "3.8.2.12",
|
"FileVersion": "3.8.2.13",
|
||||||
"InternalName": "Navis",
|
"InternalName": "Navis",
|
||||||
"LegalCopyright": "Copyright (c) EvilFox",
|
"LegalCopyright": "Copyright (c) EvilFox",
|
||||||
"OriginalFilename": "Navis.exe",
|
"OriginalFilename": "Navis.exe",
|
||||||
"ProductName": "Navis",
|
"ProductName": "Navis",
|
||||||
"ProductVersion": "3.8.2.12",
|
"ProductVersion": "3.8.2.13",
|
||||||
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
Reference in New Issue
Block a user