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:
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user