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