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:
@@ -38,6 +38,10 @@
|
||||
A17B79FE2FE52708001ABF28 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79FD2FE52708001ABF28 /* AppKit.framework */; };
|
||||
A17B7A002FE52723001ABF28 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79FF2FE52715001ABF28 /* libresolv.tbd */; };
|
||||
A17B7A072FE52A10001ABF28 /* VpnChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17B7A062FE52A10001ABF28 /* VpnChannel.swift */; };
|
||||
A17B7A0B2FE52A10001ABF28 /* StatsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17B7A0A2FE52A10001ABF28 /* StatsClient.swift */; };
|
||||
A17B7A200000000000000001 /* Libbox.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79F52FE52309001ABF28 /* Libbox.xcframework */; };
|
||||
A17B7A200000000000000002 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79FB2FE526F5001ABF28 /* SystemConfiguration.framework */; };
|
||||
A17B7A200000000000000003 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = A17B79FF2FE52715001ABF28 /* libresolv.tbd */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -128,6 +132,7 @@
|
||||
A17B79FD2FE52708001ABF28 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; };
|
||||
A17B79FF2FE52715001ABF28 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
|
||||
A17B7A062FE52A10001ABF28 /* VpnChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VpnChannel.swift; sourceTree = "<group>"; };
|
||||
A17B7A0A2FE52A10001ABF28 /* StatsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsClient.swift; sourceTree = "<group>"; };
|
||||
B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
C55C525CAAF7B3313B74AC65 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -175,6 +180,9 @@
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
5C06858DBFCE524FEBB9B432 /* Pods_Runner.framework in Frameworks */,
|
||||
A17B7A200000000000000001 /* Libbox.xcframework in Frameworks */,
|
||||
A17B7A200000000000000002 /* SystemConfiguration.framework in Frameworks */,
|
||||
A17B7A200000000000000003 /* libresolv.tbd in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -262,6 +270,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A17B7A062FE52A10001ABF28 /* VpnChannel.swift */,
|
||||
A17B7A0A2FE52A10001ABF28 /* StatsClient.swift */,
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||
@@ -583,6 +592,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A17B7A072FE52A10001ABF28 /* VpnChannel.swift in Sources */,
|
||||
A17B7A0B2FE52A10001ABF28 /* StatsClient.swift in Sources */,
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||
@@ -634,7 +644,7 @@
|
||||
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
@@ -649,7 +659,7 @@
|
||||
baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
@@ -664,7 +674,7 @@
|
||||
baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
@@ -935,7 +945,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -985,7 +995,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -1034,7 +1044,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user