bf84492eef
各原生侧把 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>
171 lines
6.9 KiB
Swift
171 lines
6.9 KiB
Swift
// 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 }
|
||
}
|
||
}
|