// StatsClient.swift — 主 app 侧实时统计消费者(macOS) // // 隧道跑在 PacketTunnel System Extension 进程里(root)。扩展和主 app 的 App Group 容器 // 不是同一路径(root 的 /var/root/... vs 用户的 /Users//...),主 app 无法直接连 // 扩展的 libbox command.sock。改走 NetworkExtension 官方的跨进程通道: // 主 app → NETunnelProviderSession.sendProviderMessage("stats") // 扩展 → handleAppMessage 回最新统计 JSON(由扩展进程内的 StatsCollector 缓存) // // 这绕开了 root/用户容器与 socket 属主的死局。字段映射到 pangolin/vpn/stats 契约 // (见 lib/bridge/vpn_bridge.dart),经 onStats 交给 VpnChannel 推到 Dart 侧。 import Foundation import NetworkExtension final class StatsClient: NSObject { /// 把契约字典交还 VpnChannel(由其推给当前 statsSink)。主队列回调。 var onStats: (([String: Any]) -> Void)? private let queue = DispatchQueue(label: "pangolin.stats.client") private weak var session: NETunnelProviderSession? private var timer: DispatchSourceTimer? // ── 生命周期 ──────────────────────────────────────────────── /// 隧道 connected 时调用,传入当前会话。每秒向扩展拉一次统计。 func start(session: NETunnelProviderSession) { queue.async { [weak self] in guard let self else { return } self.session = session 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("[pangolin/stats] polling via sendProviderMessage started") } } func stop() { queue.async { [weak self] in guard let self else { return } self.timer?.cancel() self.timer = nil self.session = nil self.emit([ "uploadBytes": 0, "downloadBytes": 0, "uploadSpeed": 0.0, "downloadSpeed": 0.0, "urltestResults": [[String: Any]](), ]) } } private func poll() { guard let session else { return } do { try session.sendProviderMessage(Data("stats".utf8)) { [weak self] resp in guard let self, let resp, !resp.isEmpty else { return } guard let obj = (try? JSONSerialization.jsonObject(with: resp)) as? [String: Any] else { return } self.emit(obj) } } catch { NSLog("[pangolin/stats] sendProviderMessage failed: %@", error.localizedDescription) } } private func emit(_ stats: [String: Any]) { DispatchQueue.main.async { [weak self] in self?.onStats?(stats) } } }