feat(client/native): stats-overhaul Phase1 — 四端实时统计采集端

各原生侧把 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-28 18:13:48 +08:00
parent 636a3bbf2f
commit bf84492eef
7 changed files with 459 additions and 14 deletions
@@ -101,6 +101,12 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler
/** 缓存的「可手动选择的出口组」tagselector 组)。selectOutbound 据此切换。 */
@Volatile private var selectableGroup: String = ""
/** writeGroups 捕获的全部出口组 tag(如 urltest 的 "auto"),供主动 urltest 探测。 */
@Volatile private var groupTags: List<String> = 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<Pair<String, Int>>()
val tags = mutableListOf<String>()
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
@@ -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 = "<group>"; };
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 = "<group>"; };
CAFE000000000001000000AA /* VpnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VpnManager.swift; sourceTree = "<group>"; };
CAFE000000000002000000AA /* StatsClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsClient.swift; sourceTree = "<group>"; };
CAFE000000000002000000AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
CAFE000000000003000000AA /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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;
+178
View File
@@ -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 ( Androidstatus group )
private var latestUrltest: [(tag: String, delayMs: Int)] = []
/// writeGroups tag urltest "auto"
private var groupTags: [String] = []
/// urltestsing-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 }
}
}
+26
View File
@@ -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()
}
}
}
+16 -6
View File
@@ -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 = "<group>"; };
A17B7A0A2FE52A10001ABF28 /* StatsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsClient.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
@@ -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;
+170
View File
@@ -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 <TeamID>.<name> 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 }
}
}
+36 -8
View File
@@ -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 {