diff --git a/client/macos/PacketTunnel/PacketTunnelProvider.swift b/client/macos/PacketTunnel/PacketTunnelProvider.swift index 1d1d348..682cf73 100644 --- a/client/macos/PacketTunnel/PacketTunnelProvider.swift +++ b/client/macos/PacketTunnel/PacketTunnelProvider.swift @@ -33,7 +33,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self else { return } do { - let configContent = try self.resolveConfig(options) + // urltest 延迟走 clash API:服务端下发的配置没有 clash_api,sing-box 不暴露 + // 出站组/urltest 历史 → libbox Group 命令返回空 → 连接页延迟为 —。这里在扩展内 + // 注入 clash_api(127.0.0.1 本地监听)+ cache_file,StatsCollector 经本地 HTTP + // 查 /proxies + /group//delay 取真实延迟(与 Windows 同法)。 + let clashPort = Int.random(in: 20000...60000) + let clashSecret = UUID().uuidString + let configContent = Self.injectClashApi(try self.resolveConfig(options), + port: clashPort, secret: clashSecret) // libbox 数据目录用扩展自己的 App Group 容器(root 运行 → /var/root/...)。 // ⚠️ 不要试图把它指向用户容器:扩展以 root 运行,command.sock 的目录/属主 @@ -69,9 +76,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { try server.startOrReloadService(configContent, options: LibboxOverrideOptions()) self.commandServer = server - // 进程内 stats 采集:连本进程自己的 command.sock(同容器,root 可达), - // 缓存最新上/下行 + urltest,供 handleAppMessage 回给主 app。 - self.stats.start() + // 进程内 stats 采集:libbox command.sock 取上/下行;clash API 取 urltest 延迟。 + self.stats.start(clashPort: clashPort, clashSecret: clashSecret) log.info("startTunnel: service started") completionHandler(nil) @@ -108,6 +114,27 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { } } + // 在 sing-box 配置里注入 experimental.clash_api(本地监听)+ cache_file,让内核暴露 + // 出站组/urltest 历史,供 StatsCollector 经本地 HTTP 取真实延迟。已有则不覆盖。 + static func injectClashApi(_ configJSON: String, port: Int, secret: String) -> String { + guard var root = (try? JSONSerialization.jsonObject(with: Data(configJSON.utf8))) + as? [String: Any] else { return configJSON } + var experimental = (root["experimental"] as? [String: Any]) ?? [:] + if experimental["clash_api"] == nil { + experimental["clash_api"] = [ + "external_controller": "127.0.0.1:\(port)", + "secret": secret, + ] + } + if experimental["cache_file"] == nil { + experimental["cache_file"] = ["enabled": true] + } + root["experimental"] = experimental + guard let data = try? JSONSerialization.data(withJSONObject: root), + let out = String(data: data, encoding: .utf8) else { return configJSON } + return out + } + private func resolveConfig(_ options: [String: NSObject]?) throws -> String { if let inline = options?["configContent"] as? String, !inline.isEmpty { return inline @@ -338,24 +365,26 @@ private func simpleError(_ msg: String) -> NSError { NSError(domain: "pangolin.tunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) } -// ── StatsCollector:扩展进程内的 libbox 命令客户端 ─────────────────────────── -// 连「本进程自己的」command.sock(同容器、root 可达,无跨进程/跨用户问题),订阅 -// status/group,缓存最新上/下行 + urltest;由 handleAppMessage 回给主 app。等价于旧的 -// 主 app 端 StatsClient,但跑在扩展里——根治主 app 跨 root/用户容器连不上 socket 的死局。 +// ── StatsCollector:扩展进程内统计采集 ────────────────────────────────────── +// 上/下行速率:连本进程自己的 command.sock(libbox Status 命令)。 +// urltest 延迟:经注入的 clash API(本地 HTTP)查 /proxies + /group//delay—— +// 服务端配置无 clash_api,sing-box 不暴露组/urltest 历史,libbox Group 命令返回空; +// 注入 clash_api 后用与 Windows 完全一致的取法(getGroupDelay)拿内核实测延迟。 +// 缓存的 latest 由 handleAppMessage 回给主 app。 final class StatsCollector: NSObject { private let queue = DispatchQueue(label: "pangolin.stats.collector") - // ⚠️ libbox 的 command 协议是「一连接一命令」:一个 client 只订阅一种命令。把 Status 与 - // Group addCommand 到同一 client,只有第一种(Status)生效,Group 永不回调 → urltest 为空、 - // 连接页延迟显示 —。对齐 sing-box-for-apple:每种命令各开一个 client。 private var statusClient: LibboxCommandClient? - private var groupClient: LibboxCommandClient? private var started = false - private var urlTestTimer: DispatchSourceTimer? + private var clashTimer: DispatchSourceTimer? + + // clash API(扩展内注入,127.0.0.1 本地监听):取出站组 urltest 真实延迟。 + private var clashBase = "" + private var clashSecret = "" + private var groupNames: [String] = [] private let lock = NSLock() private var latest: [String: Any] = StatsCollector.zero private var latestUrltest: [(tag: String, delayMs: Int)] = [] - private var groupTags: [String] = [] private static let zero: [String: Any] = [ "uploadBytes": 0, "downloadBytes": 0, @@ -363,52 +392,42 @@ final class StatsCollector: NSObject { "urltestResults": [[String: Any]](), ] - func start() { + func start(clashPort: Int, clashSecret: String) { queue.async { [weak self] in guard let self, !self.started else { return } - self.statusClient = self.connectClient(command: LibboxCommandStatus, label: "status") - self.groupClient = self.connectClient(command: LibboxCommandGroup, label: "group") - guard self.statusClient != nil || self.groupClient != nil else { return } + self.clashBase = "http://127.0.0.1:\(clashPort)" + self.clashSecret = clashSecret + self.statusClient = self.connectStatusClient() self.started = true - self.startUrlTestTimer() + self.startClashTimer() } } - /// 为单一命令开一个 client 并连上(command.sock 刚建好可能稍滞后,重试)。 - private func connectClient(command: Int32, label: String) -> LibboxCommandClient? { + /// libbox Status client(取速率)。command.sock 刚建好可能稍滞后,重试。 + private func connectStatusClient() -> LibboxCommandClient? { let options = LibboxCommandClientOptions() options.statusInterval = 1_000_000_000 // 1s - options.addCommand(command) - guard let c = LibboxNewCommandClient(self, options) else { - NSLog("[pangolin/collector] %@ LibboxNewCommandClient nil", label) - return nil - } + options.addCommand(LibboxCommandStatus) + guard let c = LibboxNewCommandClient(self, options) else { return nil } for _ in 0..<20 { - do { - try c.connect() - NSLog("[pangolin/collector] %@ connected", label) - return c - } catch { - Thread.sleep(forTimeInterval: 0.25) - } + do { try c.connect(); NSLog("[pangolin/collector] status connected"); return c } + catch { Thread.sleep(forTimeInterval: 0.25) } } - NSLog("[pangolin/collector] %@ gave up connecting", label) + NSLog("[pangolin/collector] status gave up") return nil } func stop() { queue.async { [weak self] in guard let self else { return } - self.urlTestTimer?.cancel(); self.urlTestTimer = nil + self.clashTimer?.cancel(); self.clashTimer = nil if let c = self.statusClient { try? c.disconnect() } - if let c = self.groupClient { try? c.disconnect() } self.statusClient = nil - self.groupClient = nil self.started = false + self.groupNames = [] self.lock.lock() self.latest = StatsCollector.zero self.latestUrltest = [] - self.groupTags = [] self.lock.unlock() } } @@ -418,17 +437,86 @@ final class StatsCollector: NSObject { return (try? JSONSerialization.data(withJSONObject: snap)) ?? Data("{}".utf8) } - private func startUrlTestTimer() { - urlTestTimer?.cancel() + // ── clash API 轮询:取 urltest 延迟(与 Windows getGroupDelay 同法)───────── + private func startClashTimer() { let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + 2, repeating: 10) - timer.setEventHandler { [weak self] in - guard let self, let c = self.groupClient else { return } - // groupTags 由 writeGroups 回调填充;主动触发各组 urltest,补偿 sing-box 惰性探测。 - for tag in self.groupTags { try? c.urlTest(tag) } - } + timer.schedule(deadline: .now() + 3, repeating: 8) + timer.setEventHandler { [weak self] in self?.pollClash() } timer.resume() - urlTestTimer = timer + clashTimer = timer + } + + private func pollClash() { + if groupNames.isEmpty { + clashGet("/proxies", query: nil) { [weak self] obj in + guard let self else { return } + let names = StatsCollector.extractUrltestGroups(obj) + self.queue.async { + self.groupNames = names + self.setDiag("groups=\(names)") + self.triggerGroupDelays() + } + } + } else { + triggerGroupDelays() + } + } + + private func triggerGroupDelays() { + for name in groupNames { + clashGet("/group/\(name)/delay", + query: ["url": "http://www.gstatic.com/generate_204", "timeout": "3000"]) { [weak self] obj in + guard let self, let dict = obj as? [String: Any] else { return } + var urltest: [(tag: String, delayMs: Int)] = [] + for (tag, v) in dict { + if let d = (v as? NSNumber)?.intValue { urltest.append((tag: tag, delayMs: d)) } + } + let summary = urltest.map { "\($0.tag):\($0.delayMs)" }.joined(separator: ",") + NSLog("[pangolin/collector] kernel delay(%@)=%@", name, summary) // 内核侧实测打点 + self.queue.async { + self.lock.lock() + self.latestUrltest = urltest + self.latest["urltestResults"] = urltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] } + self.latest["diag"] = "delay(\(name))=\(summary)" + self.lock.unlock() + } + } + } + } + + private func setDiag(_ s: String) { + lock.lock(); latest["diag"] = s; lock.unlock() + } + + private func clashGet(_ path: String, query: [String: String]?, + completion: @escaping (Any?) -> Void) { + guard var comps = URLComponents(string: clashBase + path) else { completion(nil); return } + if let query { comps.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } } + guard let url = comps.url else { completion(nil); return } + var req = URLRequest(url: url) + req.timeoutInterval = 8 + if !clashSecret.isEmpty { + req.setValue("Bearer \(clashSecret)", forHTTPHeaderField: "Authorization") + } + URLSession.shared.dataTask(with: req) { data, _, err in + if let err { NSLog("[pangolin/collector] clash %@ err=%@", path, err.localizedDescription) } + guard let data else { completion(nil); return } + completion(try? JSONSerialization.jsonObject(with: data)) + }.resume() + } + + /// 从 /proxies 提取 URLTest/Fallback 组名(clash 约定类型名首字母大写)。 + static func extractUrltestGroups(_ obj: Any?) -> [String] { + guard let root = obj as? [String: Any], + let proxies = root["proxies"] as? [String: Any] else { return [] } + var names: [String] = [] + for (name, v) in proxies { + if let m = v as? [String: Any], let type = m["type"] as? String, + type == "URLTest" || type == "Fallback" { + names.append(name) + } + } + return names } } @@ -444,34 +532,14 @@ extension StatsCollector: LibboxCommandClientHandlerProtocol { func writeStatus(_ message: LibboxStatusMessage?) { guard let m = message else { return } + // 逐键更新(不整盘重建),保留 clash 轮询写入的 urltestResults / diag。 lock.lock() - latest = [ - "uploadBytes": m.uplinkTotal, - "downloadBytes": m.downlinkTotal, - "uploadSpeed": Double(m.uplink), - "downloadSpeed": Double(m.downlink), - "urltestResults": latestUrltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] }, - ] + latest["uploadBytes"] = m.uplinkTotal + latest["downloadBytes"] = m.downlinkTotal + latest["uploadSpeed"] = Double(m.uplink) + latest["downloadSpeed"] = Double(m.downlink) lock.unlock() } - func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) { - guard let groups = message else { return } - var urltest: [(tag: String, delayMs: Int)] = [] - var tags: [String] = [] - while groups.hasNext() { - guard let group = groups.next() else { break } - tags.append(group.tag) - guard let items = group.getItems() else { continue } - while items.hasNext() { - guard let item = items.next() else { break } - urltest.append((tag: item.tag, delayMs: Int(item.urlTestDelay))) - } - } - lock.lock() - latestUrltest = urltest - latest["urltestResults"] = urltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] } - lock.unlock() - queue.async { [weak self] in self?.groupTags = tags } - } + func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {} } diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj index 334eb06..a3e2a2e 100644 --- a/client/macos/Runner.xcodeproj/project.pbxproj +++ b/client/macos/Runner.xcodeproj/project.pbxproj @@ -659,7 +659,7 @@ baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -674,7 +674,7 @@ baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -689,7 +689,7 @@ baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -960,7 +960,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1010,7 +1010,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1059,7 +1059,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 46; + CURRENT_PROJECT_VERSION = 47; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; diff --git a/client/macos/Runner/StatsClient.swift b/client/macos/Runner/StatsClient.swift index 265128f..e5b190a 100644 --- a/client/macos/Runner/StatsClient.swift +++ b/client/macos/Runner/StatsClient.swift @@ -67,8 +67,9 @@ final class StatsClient: NSObject { if self.diagTick % 5 == 0 { let urls = (obj["urltestResults"] as? [[String: Any]]) ?? [] let pairs = urls.map { "\($0["tag"] ?? "?")=\($0["delayMs"] ?? -1)" }.joined(separator: ",") - NSLog("[pangolin/stats] dl=%@ ul=%@ urltest[%d]=%@", - "\(obj["downloadSpeed"] ?? 0)", "\(obj["uploadSpeed"] ?? 0)", urls.count, pairs) + NSLog("[pangolin/stats] dl=%@ ul=%@ urltest[%d]=%@ diag=%@", + "\(obj["downloadSpeed"] ?? 0)", "\(obj["uploadSpeed"] ?? 0)", urls.count, pairs, + "\(obj["diag"] ?? "-")") } self.emit(obj) }