feat: sync httpdns sdk/platform updates without large binaries

This commit is contained in:
robin
2026-03-04 17:59:14 +08:00
parent 853897a6f8
commit 532891fad0
700 changed files with 6096 additions and 2712 deletions

View File

@@ -0,0 +1,324 @@
import Flutter
import Foundation
import UIKit
import CommonCrypto
public class NewHttpDnsPlugin: NSObject, FlutterPlugin {
private var appId: String?
private var secretKey: String?
private var primaryServiceHost: String?
private var backupServiceHost: String?
private var servicePort: Int = 443
private var desiredLogEnabled: Bool?
private var desiredHttpsEnabled: Bool?
private var desiredConnectTimeoutSeconds: TimeInterval = 3
private var desiredReadTimeoutSeconds: TimeInterval = 5
private lazy var sessionId: String = {
UUID().uuidString.replacingOccurrences(of: "-", with: "")
}()
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "new_httpdns", binaryMessenger: registrar.messenger())
let instance = NewHttpDnsPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialize":
let options = call.arguments as? [String: Any] ?? [:]
guard let rawAppId = options["appId"] as? String,
let rawPrimaryHost = options["primaryServiceHost"] as? String else {
result(false)
return
}
let normalizedAppId = rawAppId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPrimaryHost = rawPrimaryHost.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedAppId.isEmpty || normalizedPrimaryHost.isEmpty {
result(false)
return
}
appId = normalizedAppId
primaryServiceHost = normalizedPrimaryHost
backupServiceHost = (options["backupServiceHost"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
secretKey = (options["secretKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let p = options["servicePort"] as? Int, p > 0 {
servicePort = p
}
result(true)
case "setLogEnabled":
let args = call.arguments as? [String: Any]
desiredLogEnabled = (args?["enabled"] as? Bool) ?? false
result(nil)
case "setHttpsRequestEnabled":
let args = call.arguments as? [String: Any]
desiredHttpsEnabled = (args?["enabled"] as? Bool) ?? false
result(nil)
case "setPersistentCacheIPEnabled":
result(nil)
case "setReuseExpiredIPEnabled":
result(nil)
case "setPreResolveAfterNetworkChanged":
result(nil)
case "setIPRankingList":
result(nil)
case "setPreResolveHosts":
result(nil)
case "getSessionId":
result(sessionId)
case "cleanAllHostCache":
result(nil)
case "build":
if desiredHttpsEnabled == false {
NSLog("NewHttpDns(iOS): HTTPS is required by current protocol, force enabled")
}
if desiredLogEnabled == true {
NSLog("NewHttpDns(iOS): build success, appId=\(appId ?? "")")
}
result(appId != nil && primaryServiceHost != nil)
case "resolveHostSyncNonBlocking":
guard let args = call.arguments as? [String: Any],
let hostname = args["hostname"] as? String else {
result(["ipv4": [], "ipv6": []])
return
}
let ipType = ((args["ipType"] as? String) ?? "auto").lowercased()
resolveHost(hostname: hostname, ipType: ipType) { payload in
result(payload)
}
case "resolveHostV1":
guard let args = call.arguments as? [String: Any],
let hostname = args["hostname"] as? String else {
result(["ipv4": [], "ipv6": [], "ttl": 0])
return
}
let qtype = ((args["qtype"] as? String) ?? "A").uppercased()
let cip = (args["cip"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
resolveSingle(hostname: hostname, qtype: qtype, cip: cip) { records, ttl in
if qtype == "AAAA" {
result(["ipv4": [], "ipv6": records, "ttl": ttl])
} else {
result(["ipv4": records, "ipv6": [], "ttl": ttl])
}
}
default:
result(FlutterMethodNotImplemented)
}
}
private func resolveHost(hostname: String, ipType: String, completion: @escaping ([String: [String]]) -> Void) {
let qtypes: [String]
switch ipType {
case "ipv6", "v6":
qtypes = ["AAAA"]
case "both", "64":
qtypes = ["A", "AAAA"]
default:
qtypes = ["A"]
}
let group = DispatchGroup()
let lock = NSLock()
var ipv4: [String] = []
var ipv6: [String] = []
for qtype in qtypes {
group.enter()
resolveSingle(hostname: hostname, qtype: qtype, cip: nil) { records, _ in
lock.lock()
if qtype == "AAAA" {
ipv6.append(contentsOf: records)
} else {
ipv4.append(contentsOf: records)
}
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
completion([
"ipv4": Array(Set(ipv4)),
"ipv6": Array(Set(ipv6))
])
}
}
private func resolveSingle(hostname: String, qtype: String, cip: String?, completion: @escaping ([String], Int) -> Void) {
guard let appId = appId,
let primary = primaryServiceHost else {
completion([], 0)
return
}
var hosts: [String] = [primary]
if let backup = backupServiceHost, !backup.isEmpty, backup != primary {
hosts.append(backup)
}
func attempt(_ index: Int) {
if index >= hosts.count {
completion([], 0)
return
}
let serviceHost = hosts[index]
var components = URLComponents()
components.scheme = "https"
components.host = serviceHost
components.port = servicePort
components.path = "/resolve"
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "appId", value: appId),
URLQueryItem(name: "dn", value: hostname),
URLQueryItem(name: "qtype", value: qtype),
URLQueryItem(name: "sdk_version", value: "flutter-ios-1.0.0"),
URLQueryItem(name: "os", value: "ios"),
URLQueryItem(name: "sid", value: sessionId)
]
if let secret = secretKey, !secret.isEmpty {
let exp = String(Int(Date().timeIntervalSince1970) + 600)
let nonce = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let signRaw = "\(appId)|\(hostname.lowercased())|\(qtype.uppercased())|\(exp)|\(nonce)"
let sign = hmacSHA256Hex(message: signRaw, secret: secret)
queryItems.append(URLQueryItem(name: "exp", value: exp))
queryItems.append(URLQueryItem(name: "nonce", value: nonce))
queryItems.append(URLQueryItem(name: "sign", value: sign))
}
if let cip = cip, !cip.isEmpty {
queryItems.append(URLQueryItem(name: "cip", value: cip))
}
components.queryItems = queryItems
guard let url = components.url else {
completion([], 0)
return
}
var req = URLRequest(url: url)
req.httpMethod = "GET"
req.timeoutInterval = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds
req.setValue(serviceHost, forHTTPHeaderField: "Host")
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds
config.timeoutIntervalForResource = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds
let session = URLSession(configuration: config)
session.dataTask(with: req) { [weak self] data, _, error in
defer { session.finishTasksAndInvalidate() }
if let error = error {
if self?.desiredLogEnabled == true {
NSLog("NewHttpDns(iOS): resolve request failed host=\(serviceHost), err=\(error.localizedDescription)")
}
attempt(index + 1)
return
}
guard let data = data else {
attempt(index + 1)
return
}
let parsedIPs = Self.extractIPsFromResolveResponse(data: data, qtype: qtype)
if parsedIPs.isEmpty {
attempt(index + 1)
return
}
let ttl = Self.extractTTLFromResolveResponse(data: data)
completion(parsedIPs, ttl)
}.resume()
}
attempt(0)
}
private static func extractIPsFromResolveResponse(data: Data, qtype: String) -> [String] {
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
isResolveSuccessCode(obj["code"]),
let dataObj = obj["data"] as? [String: Any],
let records = dataObj["records"] as? [[String: Any]] else {
return []
}
var ips: [String] = []
for row in records {
let type = ((row["type"] as? String) ?? "").uppercased()
if type != qtype.uppercased() {
continue
}
if let ip = row["ip"] as? String, !ip.isEmpty {
ips.append(ip)
}
}
return ips
}
private static func extractTTLFromResolveResponse(data: Data) -> Int {
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataObj = obj["data"] as? [String: Any],
let ttlValue = dataObj["ttl"] else {
return 0
}
if let ttl = ttlValue as? Int {
return ttl
}
if let ttlString = ttlValue as? String, let ttl = Int(ttlString) {
return ttl
}
return 0
}
private static func isResolveSuccessCode(_ codeValue: Any?) -> Bool {
if let code = codeValue as? String {
let normalized = code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return normalized == "SUCCESS" || normalized == "0"
}
if let code = codeValue as? Int {
return code == 0
}
if let code = codeValue as? NSNumber {
return code.intValue == 0
}
return false
}
private func hmacSHA256Hex(message: String, secret: String) -> String {
guard let keyData = secret.data(using: .utf8),
let messageData = message.data(using: .utf8) else {
return ""
}
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
keyData.withUnsafeBytes { keyBytes in
messageData.withUnsafeBytes { msgBytes in
CCHmac(
CCHmacAlgorithm(kCCHmacAlgSHA256),
keyBytes.baseAddress,
keyData.count,
msgBytes.baseAddress,
messageData.count,
&digest
)
}
}
return digest.map { String(format: "%02x", $0) }.joined()
}
}