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>
549 lines
24 KiB
Swift
549 lines
24 KiB
Swift
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 }
|
||
}
|