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() 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.. 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[.. 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[.. 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 } }