Files
pangolin/client/macos/Runner/StatsClient.swift
T
wangjia bf84492eef 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>
2026-06-28 18:13:48 +08:00

171 lines
6.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 }
}
}