Files
navi/ios/Shared/LinkParser.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

549 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}