// StatsClient.swift — 主 app 侧实时统计生产者(macOS) // // 隧道跑在 PacketTunnel System Extension 进程里,扩展已 LibboxSetup 到 App Group 容器 // 并起了 LibboxCommandServer。主 app 用 LibboxCommandClient 连接「同一 group 容器路径」 // 下的 CommandServer socket,订阅 status/group 命令拿实时上/下行 + 延迟——对齐 iOS // StatsClient 与 sing-box-for-apple 模型,替代旧的「每秒推占位 0」骨架。 // // 字段映射到 pangolin/vpn/stats 契约(见 lib/bridge/vpn_bridge.dart),经 onStats 回调 // 交给 VpnChannel 推到 Dart 侧。生命周期由 VpnChannel 的 NEVPNStatus 观察驱动。 import Foundation import Libbox final class StatsClient: NSObject { // macOS 原生 App Group 格式 .(与扩展一致,非 iOS 的 group. 前缀)。 private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin" /// 把契约字典交还 VpnChannel(由其推给当前 statsSink)。主队列回调。 var onStats: (([String: Any]) -> Void)? private let queue = DispatchQueue(label: "pangolin.stats.client") private var client: LibboxCommandClient? private var started = false private static var didSetup = false private var latestUrltest: [(tag: String, delayMs: Int)] = [] /// writeGroups 捕获的出口组 tag,用于主动触发延迟探测。 private var groupTags: [String] = [] /// 周期主动 urltest:补偿 sing-box urltest 惰性探测,让延迟稳定及时。 private var urlTestTimer: DispatchSourceTimer? // ── 生命周期 ──────────────────────────────────────────────── 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) guard let c = LibboxNewCommandClient(self, options) else { NSLog("[pangolin/stats] LibboxNewCommandClient returned nil") return } for attempt in 0..<10 { do { try c.connect() self.client = c self.started = true NSLog("[pangolin/stats] connected (attempt %d)", attempt + 1) self.startUrlTestTimer() return } catch { NSLog("[pangolin/stats] connect failed (attempt %d): %@", attempt + 1, error.localizedDescription) Thread.sleep(forTimeInterval: 0.5) } } NSLog("[pangolin/stats] gave up connecting after retries") } } /// 周期主动触发 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 } 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 = [] self.emit([ "uploadBytes": 0, "downloadBytes": 0, "uploadSpeed": 0.0, "downloadSpeed": 0.0, "urltestResults": [[String: Any]](), ]) } } private func emit(_ stats: [String: Any]) { DispatchQueue.main.async { [weak self] in self?.onStats?(stats) } } // ── libbox 全局 setup(与扩展同一 group 容器路径)───────────── private func ensureSetup() -> Bool { if Self.didSetup { return true } guard let base = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { NSLog("[pangolin/stats] no app group container") return false } 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("[pangolin/stats] LibboxSetup failed: %@", err.localizedDescription) return false } Self.didSetup = true return true } } // ── LibboxCommandClientHandler:实时回调 ───────────────────────────── extension StatsClient: LibboxCommandClientHandlerProtocol { func connected() { NSLog("[pangolin/stats] server connected") } func disconnected(_ message: String?) { NSLog("[pangolin/stats] 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 } emit([ "uploadBytes": m.uplinkTotal, "downloadBytes": m.downlinkTotal, "uploadSpeed": Double(m.uplink), "downloadSpeed": Double(m.downlink), "urltestResults": latestUrltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] }, ]) } 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 } } }