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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user