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