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>
401 lines
15 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|