// StatsClient.swift — 主 App 侧实时统计消费者(iOS) // // 隧道跑在 PacketTunnel 扩展进程里;扩展用 StatsCollector 采集上/下行(libbox command.sock) // + urltest 延迟(注入的 clash API)并缓存最新 JSON。主 App 经 // NETunnelProviderSession.sendProviderMessage(action="stats")每秒拉一次,解析后推到 // pangolin/vpn/stats EventChannel——与 macOS 同架构(扩展产出、app 经 NE 跨进程拉取)。 // // 之前的做法(主 App 用 LibboxCommandClient 直连共享容器 socket)有两个坑:① 单 client 同时 // 订 Status+Group → Group 永不回调(libbox 一连接一命令);② 配置无 clash_api → 内核不暴露 // urltest。改走扩展 StatsCollector + sendProviderMessage 后两坑同解,与 macOS 一致。 // // 生命周期:VPN 进入 connected → start();断开/失效 → stop()。由 VpnManager 驱动。 import Foundation final class StatsClient: NSObject { static let shared = StatsClient() private let queue = DispatchQueue(label: "pangolin.stats.client") private var timer: DispatchSourceTimer? // ── 生命周期 ──────────────────────────────────────────────── /// 隧道已连接后调用:每秒向扩展拉一次统计。幂等。 func start() { queue.async { [weak self] in 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") } } /// 隧道断开后调用:停定时器并推零帧,避免 UI 残留上一次连接的速率。幂等。 func stop() { queue.async { [weak self] in guard let self else { return } self.timer?.cancel() self.timer = nil VpnStatsStreamHandler.shared.push([ "uploadBytes": 0, "downloadBytes": 0, "uploadSpeed": 0.0, "downloadSpeed": 0.0, "urltestResults": [[String: Any]](), ]) } } 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) } } }