e3fb5239a6
确认 urltest 为空是内核探测 100% 失败(传输/读取链路均正常)。本次注入 log.level=debug, StatsCollector 新增 Log 命令客户端,抓取内核日志里 urltest/gstatic 相关行(失败原因: DNS/连接/超时),塞进 stats 的 klog 字段经主 app 可读日志透出 —— 直接看到内核报错。 CURRENT_PROJECT_VERSION 51→52。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
85 lines
3.8 KiB
Swift
85 lines
3.8 KiB
Swift
// StatsClient.swift — 主 app 侧实时统计消费者(macOS)
|
||
//
|
||
// 隧道跑在 PacketTunnel System Extension 进程里(root)。扩展和主 app 的 App Group 容器
|
||
// 不是同一路径(root 的 /var/root/... vs 用户的 /Users/<user>/...),主 app 无法直接连
|
||
// 扩展的 libbox command.sock。改走 NetworkExtension 官方的跨进程通道:
|
||
// 主 app → NETunnelProviderSession.sendProviderMessage("stats")
|
||
// 扩展 → handleAppMessage 回最新统计 JSON(由扩展进程内的 StatsCollector 缓存)
|
||
//
|
||
// 这绕开了 root/用户容器与 socket 属主的死局。字段映射到 pangolin/vpn/stats 契约
|
||
// (见 lib/bridge/vpn_bridge.dart),经 onStats 交给 VpnChannel 推到 Dart 侧。
|
||
|
||
import Foundation
|
||
import NetworkExtension
|
||
|
||
final class StatsClient: NSObject {
|
||
|
||
/// 把契约字典交还 VpnChannel(由其推给当前 statsSink)。主队列回调。
|
||
var onStats: (([String: Any]) -> Void)?
|
||
|
||
private let queue = DispatchQueue(label: "pangolin.stats.client")
|
||
private weak var session: NETunnelProviderSession?
|
||
private var timer: DispatchSourceTimer?
|
||
private var diagTick = 0 // 诊断:每 5 帧打一次 urltest,排查延迟为何不刷新
|
||
|
||
// ── 生命周期 ────────────────────────────────────────────────
|
||
|
||
/// 隧道 connected 时调用,传入当前会话。每秒向扩展拉一次统计。
|
||
func start(session: NETunnelProviderSession) {
|
||
queue.async { [weak self] in
|
||
guard let self else { return }
|
||
self.session = session
|
||
self.timer?.cancel()
|
||
let t = DispatchSource.makeTimerSource(queue: self.queue)
|
||
t.schedule(deadline: .now() + 0.2, repeating: 1.0)
|
||
t.setEventHandler { [weak self] in self?.poll() }
|
||
t.resume()
|
||
self.timer = t
|
||
NSLog("[pangolin/stats] polling via sendProviderMessage started")
|
||
}
|
||
}
|
||
|
||
func stop() {
|
||
queue.async { [weak self] in
|
||
guard let self else { return }
|
||
self.timer?.cancel()
|
||
self.timer = nil
|
||
self.session = nil
|
||
self.emit([
|
||
"uploadBytes": 0, "downloadBytes": 0,
|
||
"uploadSpeed": 0.0, "downloadSpeed": 0.0,
|
||
"urltestResults": [[String: Any]](),
|
||
])
|
||
}
|
||
}
|
||
|
||
private func poll() {
|
||
guard let session else { return }
|
||
do {
|
||
try session.sendProviderMessage(Data("stats".utf8)) { [weak self] resp in
|
||
guard let self, let resp, !resp.isEmpty else { return }
|
||
guard let obj = (try? JSONSerialization.jsonObject(with: resp)) as? [String: Any] else {
|
||
NSLog("[pangolin/stats] bad stats payload (%d bytes)", resp.count)
|
||
return
|
||
}
|
||
// 诊断:确认 urltest 延迟是否随统计帧一起到达(连接页延迟靠它)。
|
||
self.diagTick += 1
|
||
if self.diagTick % 5 == 0 {
|
||
let urls = (obj["urltestResults"] as? [[String: Any]]) ?? []
|
||
let pairs = urls.map { "\($0["tag"] ?? "?")=\($0["delayMs"] ?? -1)" }.joined(separator: ",")
|
||
NSLog("[pangolin/stats] dl=%@ ul=%@ urltest[%d]=%@ diag=%@ klog=%@",
|
||
"\(obj["downloadSpeed"] ?? 0)", "\(obj["uploadSpeed"] ?? 0)", urls.count, pairs,
|
||
"\(obj["diag"] ?? "-")", "\(obj["klog"] ?? "-")")
|
||
}
|
||
self.emit(obj)
|
||
}
|
||
} catch {
|
||
NSLog("[pangolin/stats] sendProviderMessage failed: %@", error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func emit(_ stats: [String: Any]) {
|
||
DispatchQueue.main.async { [weak self] in self?.onStats?(stats) }
|
||
}
|
||
}
|