diff --git a/client/macos/PacketTunnel/PacketTunnelProvider.swift b/client/macos/PacketTunnel/PacketTunnelProvider.swift index 2b2f332..48e0eb5 100644 --- a/client/macos/PacketTunnel/PacketTunnelProvider.swift +++ b/client/macos/PacketTunnel/PacketTunnelProvider.swift @@ -13,7 +13,6 @@ import Libbox import Network import NetworkExtension import os -import SystemConfiguration private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider") // macOS 原生 App Group 格式 .(非 iOS 的 group. 前缀)。 @@ -23,6 +22,7 @@ private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin" final class PacketTunnelProvider: NEPacketTunnelProvider { private var commandServer: LibboxCommandServer? private var platform: PangolinPlatformInterface? + private let stats = StatsCollector() override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { @@ -35,24 +35,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { do { let configContent = try self.resolveConfig(options) - // ⚠️ 系统扩展以 root 运行,其 App Group 容器是 /var/root/Library/Group - // Containers/...,与用户 app 的 /Users//... 不是同一路径(容器是 home - // 相对的)。libbox 把 command.sock 建在 basePath 下 → 落在 root 容器,无 root - // 权限的 app 永远 connect 不上 → 实时统计断流(— KB/s + 延迟不刷新)。 - // 解决:把 libbox 数据目录指向「当前登录用户」的同名容器(root 可写),并在 - // 建好 socket 后 chown 给该用户,使 app 能 connect。拿不到登录用户时回退到 - // root 容器(至少隧道能跑,仅统计不可用)。 - let consoleUser = Self.consoleUser() - let base: URL - if let u = consoleUser { - base = u.home - .appendingPathComponent("Library/Group Containers/\(appGroup)", isDirectory: true) - log.info("libbox base → 登录用户容器 \(base.path, privacy: .public)") - } else if let root = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroup) { - base = root - log.error("拿不到登录用户,回退 root 容器 \(base.path, privacy: .public)(app 连不上 command.sock)") - } else { + // libbox 数据目录用扩展自己的 App Group 容器(root 运行 → /var/root/...)。 + // ⚠️ 不要试图把它指向用户容器:扩展以 root 运行,command.sock 的目录/属主 + // 与无 root 的主 app 不一致,主 app 永远 connect 不上(实测踩坑)。实时统计 + // 改走 NETunnelProviderSession.sendProviderMessage(见 handleAppMessage + + // StatsCollector):扩展进程内自连 command.sock 取状态、缓存,主 app 经 NE 的 + // 跨进程消息通道拉取,绕开容器/属主死局。 + guard let base = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { throw simpleError("no app group container") } let work = base.appendingPathComponent("work", isDirectory: true) @@ -74,21 +64,15 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { throw newErr ?? simpleError("LibboxNewCommandServer returned nil") } try server.start() - - // server.start() 已在 base 下建出 command.sock(root 拥有)。Unix socket 的 - // connect() 需要 socket 文件的写权限,仅 owner 满足 → 把容器/work/socket 都 - // chown 给登录用户,让无 root 的 app 能连。root 在用户 home 里建的文件默认 root - // 拥有,不 chown 则 app connect 被 EACCES 拒。 - if let u = consoleUser { - chown(base.path, u.uid, u.gid) - chown(work.path, u.uid, u.gid) - chown(base.appendingPathComponent("command.sock").path, u.uid, u.gid) - } // 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options, // 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针)。 try server.startOrReloadService(configContent, options: LibboxOverrideOptions()) self.commandServer = server + // 进程内 stats 采集:连本进程自己的 command.sock(同容器,root 可达), + // 缓存最新上/下行 + urltest,供 handleAppMessage 回给主 app。 + self.stats.start() + log.info("startTunnel: service started") completionHandler(nil) } catch { @@ -101,6 +85,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { log.info("stopTunnel reason=\(reason.rawValue)") + stats.stop() try? commandServer?.closeService() commandServer?.close() commandServer = nil @@ -109,26 +94,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler() } + // 主 app 经 NETunnelProviderSession.sendProviderMessage 拉取实时统计。约定: + // 发送 UTF-8 "stats" → 回最新统计 JSON(契约同 pangolin/vpn/stats);其他消息回 nil。 + // 这是 macOS 上 app↔扩展跨进程(且跨 root/用户边界)的正路,免去共享文件 socket 的 + // 容器/属主死局。 override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { - completionHandler?(nil) - } - - // 当前图形界面登录用户(GUI console user)及其 home。系统扩展据此把 libbox 数据目录 - // 指向用户容器,而非 root(/var/root)容器。拿不到(无人登录/登录窗)返回 nil。 - private static func consoleUser() -> (uid: uid_t, gid: gid_t, home: URL)? { - guard let store = SCDynamicStoreCreate(nil, "com.pangolin.pangolin.PacketTunnel" as CFString, nil, nil) else { - return nil + let req = String(data: messageData, encoding: .utf8) ?? "" + if req == "stats" { + completionHandler?(stats.latestJSON()) + } else { + completionHandler?(nil) } - var uid: uid_t = 0 - var gid: gid_t = 0 - guard let name = SCDynamicStoreCopyConsoleUser(store, &uid, &gid) as String?, - !name.isEmpty, name != "loginwindow", uid >= 500, - let pw = getpwuid(uid) else { - return nil - } - let home = URL(fileURLWithPath: String(cString: pw.pointee.pw_dir), isDirectory: true) - return (uid, gid, home) } private func resolveConfig(_ options: [String: NSObject]?) throws -> String { @@ -360,3 +337,129 @@ final class InterfaceIterator: NSObject, LibboxNetworkInterfaceIteratorProtocol private func simpleError(_ msg: String) -> NSError { NSError(domain: "pangolin.tunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) } + +// ── StatsCollector:扩展进程内的 libbox 命令客户端 ─────────────────────────── +// 连「本进程自己的」command.sock(同容器、root 可达,无跨进程/跨用户问题),订阅 +// status/group,缓存最新上/下行 + urltest;由 handleAppMessage 回给主 app。等价于旧的 +// 主 app 端 StatsClient,但跑在扩展里——根治主 app 跨 root/用户容器连不上 socket 的死局。 +final class StatsCollector: NSObject { + private let queue = DispatchQueue(label: "pangolin.stats.collector") + private var client: LibboxCommandClient? + private var started = false + private var urlTestTimer: DispatchSourceTimer? + + private let lock = NSLock() + private var latest: [String: Any] = StatsCollector.zero + private var latestUrltest: [(tag: String, delayMs: Int)] = [] + private var groupTags: [String] = [] + + private static let zero: [String: Any] = [ + "uploadBytes": 0, "downloadBytes": 0, + "uploadSpeed": 0.0, "downloadSpeed": 0.0, + "urltestResults": [[String: Any]](), + ] + + func start() { + queue.async { [weak self] in + guard let self, !self.started 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/collector] LibboxNewCommandClient nil") + return + } + // command.sock 刚由 server.start() 建好,可能稍滞后,重试到连上。 + for _ in 0..<20 { + do { + try c.connect() + self.client = c + self.started = true + self.startUrlTestTimer() + NSLog("[pangolin/collector] connected to own command.sock") + return + } catch { + Thread.sleep(forTimeInterval: 0.25) + } + } + NSLog("[pangolin/collector] gave up connecting to own command.sock") + } + } + + 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.lock.lock() + self.latest = StatsCollector.zero + self.latestUrltest = [] + self.groupTags = [] + self.lock.unlock() + } + } + + func latestJSON() -> Data { + lock.lock(); let snap = latest; lock.unlock() + return (try? JSONSerialization.data(withJSONObject: snap)) ?? Data("{}".utf8) + } + + 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 + } +} + +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 } + lock.lock() + latest = [ + "uploadBytes": m.uplinkTotal, + "downloadBytes": m.downlinkTotal, + "uploadSpeed": Double(m.uplink), + "downloadSpeed": Double(m.downlink), + "urltestResults": latestUrltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] }, + ] + lock.unlock() + } + + 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))) + } + } + lock.lock() + latestUrltest = urltest + latest["urltestResults"] = urltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] } + lock.unlock() + queue.async { [weak self] in self?.groupTags = tags } + } +} diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj index 9f10f24..0c5af93 100644 --- a/client/macos/Runner.xcodeproj/project.pbxproj +++ b/client/macos/Runner.xcodeproj/project.pbxproj @@ -659,7 +659,7 @@ baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -674,7 +674,7 @@ baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -689,7 +689,7 @@ baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -960,7 +960,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1010,7 +1010,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1059,7 +1059,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; diff --git a/client/macos/Runner/StatsClient.swift b/client/macos/Runner/StatsClient.swift index c9ab11a..530cfe6 100644 --- a/client/macos/Runner/StatsClient.swift +++ b/client/macos/Runner/StatsClient.swift @@ -1,105 +1,49 @@ -// StatsClient.swift — 主 app 侧实时统计生产者(macOS) +// 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」骨架。 +// 隧道跑在 PacketTunnel System Extension 进程里(root)。扩展和主 app 的 App Group 容器 +// 不是同一路径(root 的 /var/root/... vs 用户的 /Users//...),主 app 无法直接连 +// 扩展的 libbox command.sock。改走 NetworkExtension 官方的跨进程通道: +// 主 app → NETunnelProviderSession.sendProviderMessage("stats") +// 扩展 → handleAppMessage 回最新统计 JSON(由扩展进程内的 StatsCollector 缓存) // -// 字段映射到 pangolin/vpn/stats 契约(见 lib/bridge/vpn_bridge.dart),经 onStats 回调 -// 交给 VpnChannel 推到 Dart 侧。生命周期由 VpnChannel 的 NEVPNStatus 观察驱动。 +// 这绕开了 root/用户容器与 socket 属主的死局。字段映射到 pangolin/vpn/stats 契约 +// (见 lib/bridge/vpn_bridge.dart),经 onStats 交给 VpnChannel 推到 Dart 侧。 import Foundation -import Libbox +import NetworkExtension 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? + private weak var session: NETunnelProviderSession? + private var timer: DispatchSourceTimer? // ── 生命周期 ──────────────────────────────────────────────── - func start() { + /// 隧道 connected 时调用,传入当前会话。每秒向扩展拉一次统计。 + func start(session: NETunnelProviderSession) { 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 - } - // 打点:command.sock 由 sysext 的 LibboxCommandServer 在 group 容器内创建。 - // 若此处 sock 不存在 → 跑的是没起 CommandServer 的旧 sysext(版本没换上)。 - if let base = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: self.appGroup) { - let sock = base.appendingPathComponent("command.sock").path - let exists = FileManager.default.fileExists(atPath: sock) - NSLog("[pangolin/stats] command.sock=%@ exists=%@", sock, exists ? "YES" : "NO(sysext 未起 CommandServer/旧版)") - } - 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") + 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") } } - /// 周期主动触发 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.timer?.cancel() + self.timer = nil + self.session = nil self.emit([ "uploadBytes": 0, "downloadBytes": 0, "uploadSpeed": 0.0, "downloadSpeed": 0.0, @@ -108,71 +52,23 @@ final class StatsClient: NSObject { } } + 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 { + NSLog("[pangolin/stats] bad stats payload (%d bytes)", resp.count) + 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) } } - - // ── 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 } - } } diff --git a/client/macos/Runner/VpnChannel.swift b/client/macos/Runner/VpnChannel.swift index e03c34c..e2105b9 100644 --- a/client/macos/Runner/VpnChannel.swift +++ b/client/macos/Runner/VpnChannel.swift @@ -171,10 +171,12 @@ final class VpnChannel: NSObject { let s = Self.statusString(conn.status) vpnLog("NEVPNStatus 变化 → \(s) (raw=\(conn.status.rawValue))") self.statusSink?(s) - // 实时统计 CommandClient 跟随连接状态启停。 + // 实时统计跟随连接状态启停(经 sendProviderMessage 向扩展拉取)。 switch conn.status { case .connected: - self.statsClient.start() + if let s = conn as? NETunnelProviderSession { + self.statsClient.start(session: s) + } case .disconnected, .invalid: self.statsClient.stop() default: @@ -193,10 +195,10 @@ final class VpnChannel: NSObject { let mgr = all.first else { return } await MainActor.run { self.manager = mgr - if mgr.connection.status == .connected { + if mgr.connection.status == .connected, let s = mgr.connection as? NETunnelProviderSession { vpnLog("启动时检测到隧道已连接(raw=\(mgr.connection.status.rawValue)),主动启动实时统计") self.statusSink?(Self.statusString(mgr.connection.status)) - self.statsClient.start() + self.statsClient.start(session: s) } } } @@ -209,9 +211,12 @@ final class VpnChannel: NSObject { fileprivate func onStatsListen(_ sink: @escaping FlutterEventSink) { statsSink = sink - // 实时速率由 StatsClient(libbox CommandClient)在隧道 connected 时产出并经 - // onStats 回调推送;此处仅登记 sink。若已连接则补一次拉起。 - if manager?.connection.status == .connected { statsClient.start() } + // 实时速率由 StatsClient(经 sendProviderMessage 向扩展拉取)在隧道 connected 时 + // 产出并经 onStats 回调推送;此处仅登记 sink。若已连接则补一次拉起。 + if manager?.connection.status == .connected, + let s = manager?.connection as? NETunnelProviderSession { + statsClient.start(session: s) + } } fileprivate func onStatsCancel() { statsSink = nil }