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
+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 {