diff --git a/client/ios/PacketTunnel/PacketTunnelProvider.swift b/client/ios/PacketTunnel/PacketTunnelProvider.swift index 1004cfa..3de86f3 100644 --- a/client/ios/PacketTunnel/PacketTunnelProvider.swift +++ b/client/ios/PacketTunnel/PacketTunnelProvider.swift @@ -23,6 +23,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { private var commandServer: LibboxCommandServer? private var platform: PangolinPlatformInterface? private let memoryMonitor = MemoryMonitor() // ← iOS 新增 + private let stats = StatsCollector() // 速率(libbox)+ urltest(clash API) override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { @@ -33,7 +34,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 历史 → 连接页延迟为 —。这里在扩展内注入 clash_api(127.0.0.1 + // 本地监听)+ cache_file,StatsCollector 经本地 HTTP 查 /proxies + /group//delay + // 取真实延迟(与 macOS/Windows 同法)。 + let clashPort = Int.random(in: 20000...60000) + let clashSecret = UUID().uuidString + let configContent = Self.injectClashApi(try self.resolveConfig(options), + port: clashPort, secret: clashSecret) guard let base = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { @@ -63,6 +71,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { try server.startOrReloadService(configContent, options: LibboxOverrideOptions()) self.commandServer = server self.memoryMonitor.start() // ← iOS 新增 + // 进程内 stats 采集:libbox command.sock 取上/下行;clash API 取 urltest 延迟。 + self.stats.start(clashPort: clashPort, clashSecret: clashSecret) log.info("startTunnel: service started") completionHandler(nil) } catch { @@ -75,6 +85,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { log.info("stopTunnel reason=\(reason.rawValue)") + stats.stop() memoryMonitor.stop() // ← iOS 新增 log.info("memory peak: \(self.memoryMonitor.summaryString(), privacy: .public)") // ← iOS 新增 try? commandServer?.closeService() @@ -89,6 +100,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler: ((Data?) -> Void)?) { guard let req = try? JSONSerialization.jsonObject(with: messageData) as? [String: Any], let action = req["action"] as? String else { completionHandler?(nil); return } + // 主 App 每秒拉实时统计:回 StatsCollector 缓存的最新 JSON(契约同 pangolin/vpn/stats)。 + if action == "stats" { completionHandler?(stats.latestJSON()); return } var resp: [String: Any] = [:] switch action { case "getStatus": @@ -102,6 +115,27 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler?((try? JSONSerialization.data(withJSONObject: resp)) ?? nil) } + // 在 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?["config"] as? String, !inline.isEmpty { return inline @@ -285,10 +319,13 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol strncpy(ptr.baseAddress!.assumingMemoryBound(to: CChar.self), $0, ptr.count - 1) } } - // CTLIOCGINFO = _IOWR('N', 3, struct ctl_info)(Swift 不导出该宏,按定义算) - let ctliocginfo: UInt = 0x8000_0000 | 0x4000_0000 - | ((UInt(MemoryLayout.stride) & 0x1fff) << 16) - | (UInt(UInt8(ascii: "N")) << 8) | 3 + // CTLIOCGINFO = _IOWR('N', 3, struct ctl_info)(Swift 不导出该宏,按定义算)。 + // 拆成子表达式:整条 | 链放一行会让 Swift 类型检查器超时(报 "unable to type-check + // in reasonable time"),进而连带 ctl_info/sockaddr_ctl 误报 not in scope。 + let iocInOut: UInt = 0x8000_0000 | 0x4000_0000 + let lenField: UInt = (UInt(MemoryLayout.stride) & 0x1fff) << 16 + let groupField: UInt = UInt(UInt8(ascii: "N")) << 8 + let ctliocginfo: UInt = iocInOut | lenField | groupField | 3 for fd in Int32(0)...1024 { var addr = sockaddr_ctl() var len = socklen_t(MemoryLayout.size) @@ -340,3 +377,201 @@ final class InterfaceIterator: NSObject, LibboxNetworkInterfaceIteratorProtocol private func simpleError(_ msg: String) -> NSError { NSError(domain: "pangolin.tunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) } + +// ── StatsCollector:扩展进程内统计采集(与 macOS 同源)────────────────────── +// 上/下行速率:连本进程自己的 command.sock(libbox Status 命令)。 +// urltest 延迟:经注入的 clash API(本地 HTTP)查 /proxies + /group//delay。 +// 缓存的 latest 由 handleAppMessage(action=="stats")回给主 App。 +final class StatsCollector: NSObject { + private let queue = DispatchQueue(label: "pangolin.stats.collector") + private var statusClient: LibboxCommandClient? + private var started = false + private var clashTimer: DispatchSourceTimer? + + private var clashBase = "" + private var clashSecret = "" + + private let lock = NSLock() + private var latest: [String: Any] = StatsCollector.zero + private var latestUrltest: [(tag: String, delayMs: Int)] = [] + + private static let zero: [String: Any] = [ + "uploadBytes": 0, "downloadBytes": 0, + "uploadSpeed": 0.0, "downloadSpeed": 0.0, + "urltestResults": [[String: Any]](), + ] + + func start(clashPort: Int, clashSecret: String) { + queue.async { [weak self] in + guard let self, !self.started else { return } + self.clashBase = "http://127.0.0.1:\(clashPort)" + self.clashSecret = clashSecret + self.statusClient = self.connectCommand(LibboxCommandStatus, "status") + self.started = true + self.startClashTimer() + } + } + + /// 为单一命令开一个 libbox client 并连上。command.sock 刚建好可能稍滞后,重试。 + private func connectCommand(_ command: Int32, _ label: String) -> LibboxCommandClient? { + let options = LibboxCommandClientOptions() + options.statusInterval = 1_000_000_000 // 1s + options.addCommand(command) + 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) } + } + NSLog("[pangolin/collector] %@ gave up", label) + return nil + } + + func stop() { + queue.async { [weak self] in + guard let self else { return } + self.clashTimer?.cancel(); self.clashTimer = nil + if let c = self.statusClient { try? c.disconnect() } + self.statusClient = nil + self.started = false + self.lock.lock() + self.latest = StatsCollector.zero + self.latestUrltest = [] + self.lock.unlock() + } + } + + func latestJSON() -> Data { + lock.lock(); let snap = latest; lock.unlock() + return (try? JSONSerialization.data(withJSONObject: snap)) ?? Data("{}".utf8) + } + + // ── clash API 轮询:取 urltest 延迟 ────────────────────────── + private func startClashTimer() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 3, repeating: 8) + timer.setEventHandler { [weak self] in self?.pollClash() } + timer.resume() + clashTimer = timer + } + + // 显示源 = /proxies 里各 URLTest 成员的 history.last.delay(每拍读,缓存住,稳定); + // /group//delay 只为「让内核重测并写进 history」,其结果也顺手缓存(双保险)。 + private func pollClash() { + clashGet("/proxies") { [weak self] obj in + guard let self else { return } + let hist = StatsCollector.extractUrltestResults(obj) + let groups = StatsCollector.extractUrltestGroups(obj).filter { $0 != "GLOBAL" } + self.queue.async { + self.cacheUrltest(hist) + for name in groups { + self.clashGet("/group/\(name)/delay", + query: ["url": "http://www.gstatic.com/generate_204", + "timeout": "5000"]) { [weak self] dobj in + guard let self else { return } + let active = StatsCollector.parseDelayMap(dobj) + self.queue.async { self.cacheUrltest(active) } + } + } + } + } + } + + /// 缓存延迟(从 history 或 active 任一来源),非空才更新——失败/空不覆盖旧值。 + private func cacheUrltest(_ list: [(tag: String, delayMs: Int)]) { + guard !list.isEmpty else { return } + lock.lock() + latestUrltest = list + latest["urltestResults"] = list.map { ["tag": $0.tag, "delayMs": $0.delayMs] } + lock.unlock() + } + + private func clashGet(_ path: String, query: [String: String]? = nil, + 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, _, _ in + 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 + } + + /// 从 /proxies 提取各 URLTest/Fallback 组成员的最新 history 延迟。 + static func extractUrltestResults(_ obj: Any?) -> [(tag: String, delayMs: Int)] { + guard let root = obj as? [String: Any], + let proxies = root["proxies"] as? [String: Any] else { return [] } + var results: [(tag: String, delayMs: Int)] = [] + for (_, v) in proxies { + guard let group = v as? [String: Any], + let type = group["type"] as? String, type == "URLTest" || type == "Fallback", + let members = group["all"] as? [Any] else { continue } + for m in members { + guard let tag = m as? String, + let member = proxies[tag] as? [String: Any], + let history = member["history"] as? [Any], !history.isEmpty, + let last = history.last as? [String: Any], + let delay = (last["delay"] as? NSNumber)?.intValue, delay > 0 else { continue } + results.append((tag: tag, delayMs: delay)) + } + } + return results + } + + /// 解析 /group//delay 的 {tag: ms} 响应(含嵌套 {tag:{delay:ms}}),只留正延迟。 + static func parseDelayMap(_ obj: Any?) -> [(tag: String, delayMs: Int)] { + guard let dict = obj as? [String: Any] else { return [] } + var out: [(tag: String, delayMs: Int)] = [] + for (tag, v) in dict { + if let d = (v as? NSNumber)?.intValue, d > 0 { + out.append((tag: tag, delayMs: d)) + } else if let inner = v as? [String: Any], + let d = (inner["delay"] as? NSNumber)?.intValue, d > 0 { + out.append((tag: tag, delayMs: d)) + } + } + return out + } +} + +extension StatsCollector: LibboxCommandClientHandlerProtocol { + func connected() {} + func disconnected(_ message: String?) {} + func clearLogs() {} + func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {} + func setDefaultLogLevel(_ level: Int32) {} + func initializeClashMode(_ modeList: (any LibboxStringIteratorProtocol)?, currentMode: String?) {} + func updateClashMode(_ newMode: String?) {} + func write(_ events: LibboxConnectionEvents?) {} + + func writeStatus(_ message: LibboxStatusMessage?) { + guard let m = message else { return } + // 逐键更新(不整盘重建),保留 clash 轮询写入的 urltestResults。 + lock.lock() + 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)?) {} +} diff --git a/client/ios/Runner.xcodeproj/project.pbxproj b/client/ios/Runner.xcodeproj/project.pbxproj index 170b403..e6128aa 100644 --- a/client/ios/Runner.xcodeproj/project.pbxproj +++ b/client/ios/Runner.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; CAFE000000000001000000AB /* VpnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000001000000AA /* VpnManager.swift */; }; CAFE000000000002000000AB /* StatsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000002000000AA /* StatsClient.swift */; }; - CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000002000000AA /* PacketTunnelProvider.swift */; }; + CAFE000000000007000000AB /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000007000000AA /* PacketTunnelProvider.swift */; }; CAFE000000000004000000AB /* PacketTunnel.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CAFE000000000004000000AA /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000006000000AB /* MemoryMonitor.swift */; }; CAFE000000000081000000AB /* Libbox.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = CAFE000000000081000000AA /* Libbox.xcframework */; }; @@ -80,7 +80,7 @@ C890E0E50FB9E2909B5EA7B8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; CAFE000000000001000000AA /* VpnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VpnManager.swift; sourceTree = ""; }; CAFE000000000002000000AA /* StatsClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsClient.swift; sourceTree = ""; }; - CAFE000000000002000000AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; + CAFE000000000007000000AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; CAFE000000000003000000AA /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CAFE000000000004000000AA /* PacketTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; CAFE000000000005000000AA /* Runner.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; @@ -203,7 +203,7 @@ CAFE000000000020000000AA /* PacketTunnel */ = { isa = PBXGroup; children = ( - CAFE000000000002000000AA /* PacketTunnelProvider.swift */, + CAFE000000000007000000AA /* PacketTunnelProvider.swift */, CAFE000000000006000000AB /* MemoryMonitor.swift */, CAFE000000000006000000AA /* PacketTunnel.entitlements */, CAFE000000000003000000AA /* Info.plist */, @@ -408,7 +408,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */, + CAFE000000000007000000AB /* PacketTunnelProvider.swift in Sources */, CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/client/ios/Runner/StatsClient.swift b/client/ios/Runner/StatsClient.swift index d9ac9a0..b58433c 100644 --- a/client/ios/Runner/StatsClient.swift +++ b/client/ios/Runner/StatsClient.swift @@ -1,108 +1,47 @@ -// StatsClient.swift — 主 App 侧实时统计生产者 +// StatsClient.swift — 主 App 侧实时统计消费者(iOS) // -// iOS 的隧道跑在独立的 PacketTunnel 扩展进程里,扩展已 LibboxSetup 到 App Group 容器 -// 并起了 LibboxCommandServer。要拿实时上/下行 + 延迟,主 App 用 LibboxCommandClient -// 连接「同一个 group 容器路径」下的 CommandServer socket,订阅 status/group 命令—— -// 对齐 sing-box-for-apple(SFI) 与 Android 的 StatsHandler 模型。 +// 隧道跑在 PacketTunnel 扩展进程里;扩展用 StatsCollector 采集上/下行(libbox command.sock) +// + urltest 延迟(注入的 clash API)并缓存最新 JSON。主 App 经 +// NETunnelProviderSession.sendProviderMessage(action="stats")每秒拉一次,解析后推到 +// pangolin/vpn/stats EventChannel——与 macOS 同架构(扩展产出、app 经 NE 跨进程拉取)。 // -// CommandServer 会按 statusInterval 自动向已连接的 client 广播 box 统计;client 的 -// writeStatus/writeGroups 回调拿到数据后映射成 pangolin/vpn/stats 契约字典 -// (见 lib/bridge/vpn_bridge.dart),经 VpnStatsStreamHandler 推到 Dart 侧。 +// 之前的做法(主 App 用 LibboxCommandClient 直连共享容器 socket)有两个坑:① 单 client 同时 +// 订 Status+Group → Group 永不回调(libbox 一连接一命令);② 配置无 clash_api → 内核不暴露 +// urltest。改走扩展 StatsCollector + sendProviderMessage 后两坑同解,与 macOS 一致。 // // 生命周期:VPN 进入 connected → start();断开/失效 → stop()。由 VpnManager 驱动。 import Foundation -import Libbox -/// 把 LibboxCommandClient 的实时回调映射成 stats 契约字典并推到 EventChannel。 final class StatsClient: NSObject { static let shared = StatsClient() - // iOS App Group:必须与 PacketTunnelProvider 的 setup 路径一致。 - private let appGroup = "group.com.pangolin.pangolinVpn" - private let queue = DispatchQueue(label: "pangolin.stats.client") - private var client: LibboxCommandClient? - private var started = false - // libbox 全局 setup 每进程一次即可。 - private static var didSetup = false - - /// 缓存最近一次 writeGroups 解析出的 urltest 延迟(tag → delayMs), - /// 在 writeStatus 时一并打包(对齐 Android:status 与 group 是两个命令流)。 - private var latestUrltest: [(tag: String, delayMs: Int)] = [] - - /// writeGroups 捕获的出口组 tag(如 urltest 的 "auto"),用于主动触发延迟探测。 - private var groupTags: [String] = [] - /// 周期主动 urltest:sing-box urltest 默认惰性探测(首个 interval 前 history 为空), - /// 这里每 12s 主动触发一次,让延迟稳定、及时刷新。 - private var urlTestTimer: DispatchSourceTimer? + private var timer: DispatchSourceTimer? // ── 生命周期 ──────────────────────────────────────────────── - /// 隧道已连接后调用:建立并连接 CommandClient。幂等。 + /// 隧道已连接后调用:每秒向扩展拉一次统计。幂等。 func start() { queue.async { [weak self] in - guard let self, !self.started else { return } - guard self.ensureSetup() else { return } - - let options = LibboxCommandClientOptions() - options.statusInterval = 1_000_000_000 // 1s(纳秒) - options.addCommand(LibboxCommandStatus) // 实时上/下行 + 累计 - options.addCommand(LibboxCommandGroup) // 出口组 → urltest 延迟 - - guard let c = LibboxNewCommandClient(self, options) else { - NSLog("StatsClient: LibboxNewCommandClient returned nil") - return - } - // CommandServer 在扩展刚起时可能还没 ready,连不上时重试几次。 - for attempt in 0..<10 { - do { - try c.connect() - self.client = c - self.started = true - NSLog("StatsClient: connected (attempt %d)", attempt + 1) - self.startUrlTestTimer() - return - } catch { - NSLog("StatsClient: connect failed (attempt %d): %@", attempt + 1, - error.localizedDescription) - Thread.sleep(forTimeInterval: 0.5) - } - } - NSLog("StatsClient: gave up connecting after retries") + guard let self else { return } + self.timer?.cancel() + let t = DispatchSource.makeTimerSource(queue: self.queue) + t.schedule(deadline: .now() + 0.2, repeating: 1.0) + t.setEventHandler { [weak self] in self?.poll() } + t.resume() + self.timer = t + NSLog("StatsClient: polling via sendProviderMessage started") } } - /// 隧道断开后调用:断开并释放 CommandClient。幂等。 - /// 周期主动触发 urltest 探测(在 queue 上)。 - private func startUrlTestTimer() { - urlTestTimer?.cancel() - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + 2, repeating: 12) - timer.setEventHandler { [weak self] in - guard let self, let c = self.client else { return } - for tag in self.groupTags { - try? c.urlTest(tag) - } - } - timer.resume() - urlTestTimer = timer - } - + /// 隧道断开后调用:停定时器并推零帧,避免 UI 残留上一次连接的速率。幂等。 func stop() { queue.async { [weak self] in guard let self else { return } - self.urlTestTimer?.cancel() - self.urlTestTimer = nil - if let c = self.client { - try? c.disconnect() - } - self.client = nil - self.started = false - self.latestUrltest = [] - self.groupTags = [] - // 断开后清零,避免 UI 残留上一次连接的速率。 + self.timer?.cancel() + self.timer = nil VpnStatsStreamHandler.shared.push([ "uploadBytes": 0, "downloadBytes": 0, "uploadSpeed": 0.0, "downloadSpeed": 0.0, @@ -111,68 +50,12 @@ final class StatsClient: NSObject { } } - // ── libbox 全局 setup(与扩展同一 group 容器路径)───────────── - - private func ensureSetup() -> Bool { - if Self.didSetup { return true } - guard let base = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { - NSLog("StatsClient: no app group container") - return false + private func poll() { + let req = (try? JSONSerialization.data(withJSONObject: ["action": "stats"])) ?? Data() + VpnManager.shared.sendProviderMessage(req) { resp, _ in + guard let resp, !resp.isEmpty, + let obj = (try? JSONSerialization.jsonObject(with: resp)) as? [String: Any] else { return } + VpnStatsStreamHandler.shared.push(obj) } - let work = base.appendingPathComponent("work", isDirectory: true) - let setup = LibboxSetupOptions() - setup.basePath = base.path - setup.workingPath = work.path - setup.tempPath = NSTemporaryDirectory() - var err: NSError? - LibboxSetup(setup, &err) - if let err { - NSLog("StatsClient: LibboxSetup failed: %@", err.localizedDescription) - return false - } - Self.didSetup = true - return true - } -} - -// ── LibboxCommandClientHandler:实时回调 ───────────────────────────── -extension StatsClient: LibboxCommandClientHandlerProtocol { - func connected() { NSLog("StatsClient: server connected") } - func disconnected(_ message: String?) { NSLog("StatsClient: server disconnected: %@", message ?? "") } - func clearLogs() {} - func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {} - func setDefaultLogLevel(_ level: Int32) {} - func initializeClashMode(_ modeList: (any LibboxStringIteratorProtocol)?, currentMode: String?) {} - func updateClashMode(_ newMode: String?) {} - func write(_ events: LibboxConnectionEvents?) {} - - func writeStatus(_ message: LibboxStatusMessage?) { - guard let m = message else { return } - let stats: [String: Any] = [ - "uploadBytes": m.uplinkTotal, - "downloadBytes": m.downlinkTotal, - "uploadSpeed": Double(m.uplink), - "downloadSpeed": Double(m.downlink), - "urltestResults": latestUrltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] }, - ] - VpnStatsStreamHandler.shared.push(stats) - } - - 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))) - } - } - latestUrltest = urltest - queue.async { [weak self] in self?.groupTags = tags } } }