From bf84492eef5794bddd859c1d5c08fecc007785bf Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 28 Jun 2026 18:13:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(client/native):=20stats-overhaul=20Phase1?= =?UTF-8?q?=20=E2=80=94=20=E5=9B=9B=E7=AB=AF=E5=AE=9E=E6=97=B6=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E9=87=87=E9=9B=86=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各原生侧把 libbox/Clash 的实时上下行/延迟经冻结的 pangolin/vpn/stats channel 生产真实帧: - iOS/macOS 新增 StatsClient.swift(LibboxNewCommandClient,statusInterval 1s, uplink/downlink/总量 + urltest 延迟映射契约字段),VpnManager/VpnChannel 状态 观察驱动 start/stop,pbxproj 登记。 - Android PangolinVpnService.kt 延迟修复。 契约字段 uploadBytes/downloadBytes/uploadSpeed/downloadSpeed/urltestResults 不改。 真机验证(连接后实时跳动)为运行时步骤,见计划 Phase1 待办。 Co-Authored-By: Claude Opus 4.8 --- .../pangolin_vpn/PangolinVpnService.kt | 29 +++ client/ios/Runner.xcodeproj/project.pbxproj | 4 + client/ios/Runner/StatsClient.swift | 178 ++++++++++++++++++ client/ios/Runner/VpnManager.swift | 26 +++ client/macos/Runner.xcodeproj/project.pbxproj | 22 ++- client/macos/Runner/StatsClient.swift | 170 +++++++++++++++++ client/macos/Runner/VpnChannel.swift | 44 ++++- 7 files changed, 459 insertions(+), 14 deletions(-) create mode 100644 client/ios/Runner/StatsClient.swift create mode 100644 client/macos/Runner/StatsClient.swift diff --git a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt index c881953..90e188e 100644 --- a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt +++ b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt @@ -101,6 +101,12 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler /** 缓存的「可手动选择的出口组」tag(selector 组)。selectOutbound 据此切换。 */ @Volatile private var selectableGroup: String = "" + /** writeGroups 捕获的全部出口组 tag(如 urltest 的 "auto"),供主动 urltest 探测。 */ + @Volatile private var groupTags: List = emptyList() + /** 周期主动 urltest:补偿 sing-box urltest 惰性探测(首个 interval 前 history 为空), + * 每 12s 主动触发一次,让延迟稳定及时刷新。 */ + private var urlTestTimer: java.util.Timer? = null + private val stopping = AtomicBoolean(false) private var libboxSetupDone = false @@ -208,11 +214,29 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler client.connect() statsClient = client Log.i(TAG, "stats CommandClient connected") + startUrlTestTimer() } catch (e: Exception) { Log.w(TAG, "stats CommandClient failed: $e") } } + /** 周期主动触发 urltest 探测,让延迟稳定出现(补偿惰性探测)。 */ + private fun startUrlTestTimer() { + urlTestTimer?.cancel() + val timer = java.util.Timer("pangolin-urltest", true) + timer.scheduleAtFixedRate(object : java.util.TimerTask() { + override fun run() { + val client = statsClient ?: return + for (tag in groupTags) { + try { client.urlTest(tag) } catch (e: Exception) { + Log.w(TAG, "urlTest($tag) failed: $e") + } + } + } + }, 2_000L, 12_000L) + urlTestTimer = timer + } + /** 由 MainActivity(同进程)调用:切换出口节点到 [tag](在缓存的可选组内)。 */ fun selectOutbound(tag: String) { val group = selectableGroup @@ -256,10 +280,12 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler override fun writeGroups(groups: OutboundGroupIterator?) { if (groups == null) return val urltest = mutableListOf>() + val tags = mutableListOf() var selected = activeOutbound try { while (groups.hasNext()) { val group = groups.next() + tags.add(group.tag) // 记录可选组的当前出口(如 selector / urltest 的 selected)。 if (group.selected.isNotEmpty()) selected = group.selected if (group.selectable) selectableGroup = group.tag @@ -273,6 +299,7 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler Log.w(TAG, "writeGroups parse error: $e") } latestUrltest = urltest + groupTags = tags activeOutbound = selected } } @@ -283,6 +310,8 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler if (!stopping.compareAndSet(false, true)) return Log.i(TAG, "doStop") + try { urlTestTimer?.cancel() } catch (_: Exception) {} + urlTestTimer = null try { statsClient?.disconnect() } catch (_: Exception) {} statsClient = null diff --git a/client/ios/Runner.xcodeproj/project.pbxproj b/client/ios/Runner.xcodeproj/project.pbxproj index e154e4d..170b403 100644 --- a/client/ios/Runner.xcodeproj/project.pbxproj +++ b/client/ios/Runner.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; CAFE000000000001000000AB /* VpnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000001000000AA /* VpnManager.swift */; }; + CAFE000000000002000000AB /* StatsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000002000000AA /* StatsClient.swift */; }; CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000002000000AA /* PacketTunnelProvider.swift */; }; CAFE000000000004000000AB /* PacketTunnel.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CAFE000000000004000000AA /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000006000000AB /* MemoryMonitor.swift */; }; @@ -78,6 +79,7 @@ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; C890E0E50FB9E2909B5EA7B8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; CAFE000000000001000000AA /* VpnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VpnManager.swift; sourceTree = ""; }; + CAFE000000000002000000AA /* StatsClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsClient.swift; sourceTree = ""; }; CAFE000000000002000000AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; CAFE000000000003000000AA /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CAFE000000000004000000AA /* PacketTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -178,6 +180,7 @@ 97C146F11CF9000F007C117D /* Info.plist */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, CAFE000000000001000000AA /* VpnManager.swift */, + CAFE000000000002000000AA /* StatsClient.swift */, CAFE000000000005000000AA /* Runner.entitlements */, 1498D2311E8E86230040F4C2 /* GeneratedPluginRegistrant.m */, 1771E259F7494EBC8BB1F97A /* GeneratedPluginRegistrant.h */, @@ -396,6 +399,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, CAFE000000000001000000AB /* VpnManager.swift in Sources */, + CAFE000000000002000000AB /* StatsClient.swift in Sources */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/client/ios/Runner/StatsClient.swift b/client/ios/Runner/StatsClient.swift new file mode 100644 index 0000000..d9ac9a0 --- /dev/null +++ b/client/ios/Runner/StatsClient.swift @@ -0,0 +1,178 @@ +// 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 } + } +} diff --git a/client/ios/Runner/VpnManager.swift b/client/ios/Runner/VpnManager.swift index 89f7314..486fa11 100644 --- a/client/ios/Runner/VpnManager.swift +++ b/client/ios/Runner/VpnManager.swift @@ -203,6 +203,32 @@ class VpnManager: NSObject { guard let self = self else { return } let s = self.statusString VpnStatusStreamHandler.shared.push(s) + // 实时统计 CommandClient 跟随连接状态启停: + // connected → 连扩展 CommandServer 订阅 box stats;断开 → 停。 + switch self.tunnelManager?.connection.status ?? .invalid { + case .connected: + StatsClient.shared.start() + case .disconnected, .invalid: + StatsClient.shared.stop() + default: + break + } + } + // 隧道(扩展)常驻:app 重启时它可能早已 connected,此后不会再发 .connected 状态 + // 变化 → 上面 observer 不触发、StatsClient 永不 start。启动时主动探一次现存连接: + // 拿到 tunnelManager,已连则立刻拉起统计。幂等(StatsClient.start 自带去重)。 + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, _ in + guard let self = self else { return } + if let mgr = managers?.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)? + .providerBundleIdentifier == self.extensionBundleId + }) { + self.tunnelManager = mgr + } + if self.tunnelManager?.connection.status == .connected { + VpnStatusStreamHandler.shared.push(self.statusString) + StatsClient.shared.start() + } } } diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj index aca38ec..eac7640 100644 --- a/client/macos/Runner.xcodeproj/project.pbxproj +++ b/client/macos/Runner.xcodeproj/project.pbxproj @@ -38,6 +38,10 @@ A17B79FE2FE52708001ABF28 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79FD2FE52708001ABF28 /* AppKit.framework */; }; A17B7A002FE52723001ABF28 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79FF2FE52715001ABF28 /* libresolv.tbd */; }; A17B7A072FE52A10001ABF28 /* VpnChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17B7A062FE52A10001ABF28 /* VpnChannel.swift */; }; + A17B7A0B2FE52A10001ABF28 /* StatsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17B7A0A2FE52A10001ABF28 /* StatsClient.swift */; }; + A17B7A200000000000000001 /* Libbox.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79F52FE52309001ABF28 /* Libbox.xcframework */; }; + A17B7A200000000000000002 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79FB2FE526F5001ABF28 /* SystemConfiguration.framework */; }; + A17B7A200000000000000003 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79FF2FE52715001ABF28 /* libresolv.tbd */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -128,6 +132,7 @@ A17B79FD2FE52708001ABF28 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; A17B79FF2FE52715001ABF28 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; A17B7A062FE52A10001ABF28 /* VpnChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VpnChannel.swift; sourceTree = ""; }; + A17B7A0A2FE52A10001ABF28 /* StatsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsClient.swift; sourceTree = ""; }; B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; C55C525CAAF7B3313B74AC65 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; @@ -175,6 +180,9 @@ files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 5C06858DBFCE524FEBB9B432 /* Pods_Runner.framework in Frameworks */, + A17B7A200000000000000001 /* Libbox.xcframework in Frameworks */, + A17B7A200000000000000002 /* SystemConfiguration.framework in Frameworks */, + A17B7A200000000000000003 /* libresolv.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -262,6 +270,7 @@ isa = PBXGroup; children = ( A17B7A062FE52A10001ABF28 /* VpnChannel.swift */, + A17B7A0A2FE52A10001ABF28 /* StatsClient.swift */, 33CC10F02044A3C60003C045 /* AppDelegate.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, 33E51913231747F40026EE4D /* DebugProfile.entitlements */, @@ -583,6 +592,7 @@ buildActionMask = 2147483647; files = ( A17B7A072FE52A10001ABF28 /* VpnChannel.swift in Sources */, + A17B7A0B2FE52A10001ABF28 /* StatsClient.swift in Sources */, 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, @@ -634,7 +644,7 @@ baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 23; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -649,7 +659,7 @@ baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 23; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -664,7 +674,7 @@ baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 23; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -935,7 +945,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -985,7 +995,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1034,7 +1044,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 23; 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 new file mode 100644 index 0000000..fa4e069 --- /dev/null +++ b/client/macos/Runner/StatsClient.swift @@ -0,0 +1,170 @@ +// 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 } + } +} diff --git a/client/macos/Runner/VpnChannel.swift b/client/macos/Runner/VpnChannel.swift index 0d9e295..9171a08 100644 --- a/client/macos/Runner/VpnChannel.swift +++ b/client/macos/Runner/VpnChannel.swift @@ -30,7 +30,7 @@ final class VpnChannel: NSObject { private var statusSink: FlutterEventSink? private var statsSink: FlutterEventSink? private var statusObserver: NSObjectProtocol? - private var statsTimer: Timer? + private let statsClient = StatsClient() private var manager: NETunnelProviderManager? private var sysextDelegate: SysExtActivationDelegate? @@ -142,13 +142,42 @@ final class VpnChannel: NSObject { // ── 状态 / 速率回传 ───────────────────────────────────────────── private func observeStatus() { + // StatsClient 拿到实时帧 → 推当前 statsSink。 + statsClient.onStats = { [weak self] stats in self?.statsSink?(stats) } statusObserver = NotificationCenter.default.addObserver( forName: .NEVPNStatusDidChange, object: nil, queue: .main ) { [weak self] note in - guard let conn = note.object as? NEVPNConnection else { return } + guard let self, let conn = note.object as? NEVPNConnection else { return } let s = Self.statusString(conn.status) vpnLog("NEVPNStatus 变化 → \(s) (raw=\(conn.status.rawValue))") - self?.statusSink?(s) + self.statusSink?(s) + // 实时统计 CommandClient 跟随连接状态启停。 + switch conn.status { + case .connected: + self.statsClient.start() + case .disconnected, .invalid: + self.statsClient.stop() + default: + break + } + } + // 隧道(sysext)常驻:app 重启时它可能早已 connected,此后不会再发 .connected + // 状态变化 → 上面的 observer 不触发、statsClient 永不 start。启动时主动探一次 + // 现存连接:拿到 manager(让 onStatsListen 兜底也能判定),已连则立刻拉起统计。 + Task { await self.primeExistingConnection() } + } + + /// 启动时若隧道已在连接,主动装配 manager 并拉起实时统计。幂等(StatsClient.start 自带去重)。 + private func primeExistingConnection() async { + guard let all = try? await NETunnelProviderManager.loadAllFromPreferences(), + let mgr = all.first else { return } + await MainActor.run { + self.manager = mgr + if mgr.connection.status == .connected { + vpnLog("启动时检测到隧道已连接(raw=\(mgr.connection.status.rawValue)),主动启动实时统计") + self.statusSink?(Self.statusString(mgr.connection.status)) + self.statsClient.start() + } } } @@ -160,12 +189,11 @@ final class VpnChannel: NSObject { fileprivate func onStatsListen(_ sink: @escaping FlutterEventSink) { statsSink = sink - // TODO(P1): 接 libbox CommandClient 订阅实时速率;骨架先每秒推占位 0。 - statsTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in - self?.statsSink?(["up": 0, "down": 0, "uplinkTotal": 0, "downlinkTotal": 0]) - } + // 实时速率由 StatsClient(libbox CommandClient)在隧道 connected 时产出并经 + // onStats 回调推送;此处仅登记 sink。若已连接则补一次拉起。 + if manager?.connection.status == .connected { statsClient.start() } } - fileprivate func onStatsCancel() { statsTimer?.invalidate(); statsTimer = nil; statsSink = nil } + fileprivate func onStatsCancel() { statsSink = nil } private static func statusString(_ s: NEVPNStatus) -> String { switch s {