Files
navi/ios/Shared/XrayConfigBuilder.swift
T
M4andCursor 7ceb3a5148
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
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>
2026-08-01 22:46:32 +03:00

401 lines
15 KiB
Swift

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
}
}
}