// StatsClient.swift — 主 App 侧实时统计生产者 // // iOS 的隧道跑在独立的 PacketTunnel 扩展进程里,扩展已 LibboxSetup 到 App Group 容器 // 并起了 LibboxCommandServer。要拿实时上/下行 + 延迟,主 App 用 LibboxCommandClient // 连接「同一个 group 容器路径」下的 CommandServer socket,订阅 status/group 命令—— // 对齐 sing-box-for-apple(SFI) 与 Android 的 StatsHandler 模型。 // // CommandServer 会按 statusInterval 自动向已连接的 client 广播 box 统计;client 的 // writeStatus/writeGroups 回调拿到数据后映射成 pangolin/vpn/stats 契约字典 // (见 lib/bridge/vpn_bridge.dart),经 VpnStatsStreamHandler 推到 Dart 侧。 // // 生命周期: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? // ── 生命周期 ──────────────────────────────────────────────── /// 隧道已连接后调用:建立并连接 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") } } /// 隧道断开后调用:断开并释放 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 } 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 残留上一次连接的速率。 VpnStatsStreamHandler.shared.push([ "uploadBytes": 0, "downloadBytes": 0, "uploadSpeed": 0.0, "downloadSpeed": 0.0, "urltestResults": [[String: Any]](), ]) } } // ── 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 } 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 } } }