fix(client/macos): 实时统计改走 sendProviderMessage,根治容器路径死局
上一版试图让 root 扩展把 command.sock 建到用户容器,反而让 startTunnel 抛错、隧道连不上
(root 在用户 home 建 socket、属主/路径与无 root 的 app 对不上,且方向本就错)。回退该路子。
正解:用 NetworkExtension 官方跨进程通道,绕开「root 扩展容器 vs 用户 app 容器」死局——
- 扩展内新增 StatsCollector:连本进程自己的 command.sock(同容器、root 可达),订阅
status/group 缓存最新上下行 + urltest;handleAppMessage 收到 "stats" 即回最新 JSON。
- 主 app StatsClient 改为每秒 NETunnelProviderSession.sendProviderMessage("stats") 拉取、
解析后推 onStats(不再直连 socket);VpnChannel 三处启动点改传 session。
- 扩展代码变更 → CURRENT_PROJECT_VERSION 44→45,让 sysextd 重装。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,6 @@ import Libbox
|
||||
import Network
|
||||
import NetworkExtension
|
||||
import os
|
||||
import SystemConfiguration
|
||||
|
||||
private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider")
|
||||
// macOS 原生 App Group 格式 <TeamID>.<name>(非 iOS 的 group. 前缀)。
|
||||
@@ -23,6 +22,7 @@ private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin"
|
||||
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
private var commandServer: LibboxCommandServer?
|
||||
private var platform: PangolinPlatformInterface?
|
||||
private let stats = StatsCollector()
|
||||
|
||||
override func startTunnel(options: [String: NSObject]?,
|
||||
completionHandler: @escaping (Error?) -> Void) {
|
||||
@@ -35,24 +35,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
do {
|
||||
let configContent = try self.resolveConfig(options)
|
||||
|
||||
// ⚠️ 系统扩展以 root 运行,其 App Group 容器是 /var/root/Library/Group
|
||||
// Containers/...,与用户 app 的 /Users/<user>/... 不是同一路径(容器是 home
|
||||
// 相对的)。libbox 把 command.sock 建在 basePath 下 → 落在 root 容器,无 root
|
||||
// 权限的 app 永远 connect 不上 → 实时统计断流(— KB/s + 延迟不刷新)。
|
||||
// 解决:把 libbox 数据目录指向「当前登录用户」的同名容器(root 可写),并在
|
||||
// 建好 socket 后 chown 给该用户,使 app 能 connect。拿不到登录用户时回退到
|
||||
// root 容器(至少隧道能跑,仅统计不可用)。
|
||||
let consoleUser = Self.consoleUser()
|
||||
let base: URL
|
||||
if let u = consoleUser {
|
||||
base = u.home
|
||||
.appendingPathComponent("Library/Group Containers/\(appGroup)", isDirectory: true)
|
||||
log.info("libbox base → 登录用户容器 \(base.path, privacy: .public)")
|
||||
} else if let root = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) {
|
||||
base = root
|
||||
log.error("拿不到登录用户,回退 root 容器 \(base.path, privacy: .public)(app 连不上 command.sock)")
|
||||
} else {
|
||||
// libbox 数据目录用扩展自己的 App Group 容器(root 运行 → /var/root/...)。
|
||||
// ⚠️ 不要试图把它指向用户容器:扩展以 root 运行,command.sock 的目录/属主
|
||||
// 与无 root 的主 app 不一致,主 app 永远 connect 不上(实测踩坑)。实时统计
|
||||
// 改走 NETunnelProviderSession.sendProviderMessage(见 handleAppMessage +
|
||||
// StatsCollector):扩展进程内自连 command.sock 取状态、缓存,主 app 经 NE 的
|
||||
// 跨进程消息通道拉取,绕开容器/属主死局。
|
||||
guard let base = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
||||
throw simpleError("no app group container")
|
||||
}
|
||||
let work = base.appendingPathComponent("work", isDirectory: true)
|
||||
@@ -74,21 +64,15 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
throw newErr ?? simpleError("LibboxNewCommandServer returned nil")
|
||||
}
|
||||
try server.start()
|
||||
|
||||
// server.start() 已在 base 下建出 command.sock(root 拥有)。Unix socket 的
|
||||
// connect() 需要 socket 文件的写权限,仅 owner 满足 → 把容器/work/socket 都
|
||||
// chown 给登录用户,让无 root 的 app 能连。root 在用户 home 里建的文件默认 root
|
||||
// 拥有,不 chown 则 app connect 被 EACCES 拒。
|
||||
if let u = consoleUser {
|
||||
chown(base.path, u.uid, u.gid)
|
||||
chown(work.path, u.uid, u.gid)
|
||||
chown(base.appendingPathComponent("command.sock").path, u.uid, u.gid)
|
||||
}
|
||||
// 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options,
|
||||
// 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针)。
|
||||
try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
|
||||
self.commandServer = server
|
||||
|
||||
// 进程内 stats 采集:连本进程自己的 command.sock(同容器,root 可达),
|
||||
// 缓存最新上/下行 + urltest,供 handleAppMessage 回给主 app。
|
||||
self.stats.start()
|
||||
|
||||
log.info("startTunnel: service started")
|
||||
completionHandler(nil)
|
||||
} catch {
|
||||
@@ -101,6 +85,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
override func stopTunnel(with reason: NEProviderStopReason,
|
||||
completionHandler: @escaping () -> Void) {
|
||||
log.info("stopTunnel reason=\(reason.rawValue)")
|
||||
stats.stop()
|
||||
try? commandServer?.closeService()
|
||||
commandServer?.close()
|
||||
commandServer = nil
|
||||
@@ -109,26 +94,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
// 主 app 经 NETunnelProviderSession.sendProviderMessage 拉取实时统计。约定:
|
||||
// 发送 UTF-8 "stats" → 回最新统计 JSON(契约同 pangolin/vpn/stats);其他消息回 nil。
|
||||
// 这是 macOS 上 app↔扩展跨进程(且跨 root/用户边界)的正路,免去共享文件 socket 的
|
||||
// 容器/属主死局。
|
||||
override func handleAppMessage(_ messageData: Data,
|
||||
completionHandler: ((Data?) -> Void)?) {
|
||||
completionHandler?(nil)
|
||||
}
|
||||
|
||||
// 当前图形界面登录用户(GUI console user)及其 home。系统扩展据此把 libbox 数据目录
|
||||
// 指向用户容器,而非 root(/var/root)容器。拿不到(无人登录/登录窗)返回 nil。
|
||||
private static func consoleUser() -> (uid: uid_t, gid: gid_t, home: URL)? {
|
||||
guard let store = SCDynamicStoreCreate(nil, "com.pangolin.pangolin.PacketTunnel" as CFString, nil, nil) else {
|
||||
return nil
|
||||
let req = String(data: messageData, encoding: .utf8) ?? ""
|
||||
if req == "stats" {
|
||||
completionHandler?(stats.latestJSON())
|
||||
} else {
|
||||
completionHandler?(nil)
|
||||
}
|
||||
var uid: uid_t = 0
|
||||
var gid: gid_t = 0
|
||||
guard let name = SCDynamicStoreCopyConsoleUser(store, &uid, &gid) as String?,
|
||||
!name.isEmpty, name != "loginwindow", uid >= 500,
|
||||
let pw = getpwuid(uid) else {
|
||||
return nil
|
||||
}
|
||||
let home = URL(fileURLWithPath: String(cString: pw.pointee.pw_dir), isDirectory: true)
|
||||
return (uid, gid, home)
|
||||
}
|
||||
|
||||
private func resolveConfig(_ options: [String: NSObject]?) throws -> String {
|
||||
@@ -360,3 +337,129 @@ final class InterfaceIterator: NSObject, LibboxNetworkInterfaceIteratorProtocol
|
||||
private func simpleError(_ msg: String) -> NSError {
|
||||
NSError(domain: "pangolin.tunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
|
||||
// ── StatsCollector:扩展进程内的 libbox 命令客户端 ───────────────────────────
|
||||
// 连「本进程自己的」command.sock(同容器、root 可达,无跨进程/跨用户问题),订阅
|
||||
// status/group,缓存最新上/下行 + urltest;由 handleAppMessage 回给主 app。等价于旧的
|
||||
// 主 app 端 StatsClient,但跑在扩展里——根治主 app 跨 root/用户容器连不上 socket 的死局。
|
||||
final class StatsCollector: NSObject {
|
||||
private let queue = DispatchQueue(label: "pangolin.stats.collector")
|
||||
private var client: LibboxCommandClient?
|
||||
private var started = false
|
||||
private var urlTestTimer: DispatchSourceTimer?
|
||||
|
||||
private let lock = NSLock()
|
||||
private var latest: [String: Any] = StatsCollector.zero
|
||||
private var latestUrltest: [(tag: String, delayMs: Int)] = []
|
||||
private var groupTags: [String] = []
|
||||
|
||||
private static let zero: [String: Any] = [
|
||||
"uploadBytes": 0, "downloadBytes": 0,
|
||||
"uploadSpeed": 0.0, "downloadSpeed": 0.0,
|
||||
"urltestResults": [[String: Any]](),
|
||||
]
|
||||
|
||||
func start() {
|
||||
queue.async { [weak self] in
|
||||
guard let self, !self.started 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/collector] LibboxNewCommandClient nil")
|
||||
return
|
||||
}
|
||||
// command.sock 刚由 server.start() 建好,可能稍滞后,重试到连上。
|
||||
for _ in 0..<20 {
|
||||
do {
|
||||
try c.connect()
|
||||
self.client = c
|
||||
self.started = true
|
||||
self.startUrlTestTimer()
|
||||
NSLog("[pangolin/collector] connected to own command.sock")
|
||||
return
|
||||
} catch {
|
||||
Thread.sleep(forTimeInterval: 0.25)
|
||||
}
|
||||
}
|
||||
NSLog("[pangolin/collector] gave up connecting to own command.sock")
|
||||
}
|
||||
}
|
||||
|
||||
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.lock.lock()
|
||||
self.latest = StatsCollector.zero
|
||||
self.latestUrltest = []
|
||||
self.groupTags = []
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func latestJSON() -> Data {
|
||||
lock.lock(); let snap = latest; lock.unlock()
|
||||
return (try? JSONSerialization.data(withJSONObject: snap)) ?? Data("{}".utf8)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
extension StatsCollector: LibboxCommandClientHandlerProtocol {
|
||||
func connected() {}
|
||||
func disconnected(_ message: String?) {}
|
||||
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 }
|
||||
lock.lock()
|
||||
latest = [
|
||||
"uploadBytes": m.uplinkTotal,
|
||||
"downloadBytes": m.downlinkTotal,
|
||||
"uploadSpeed": Double(m.uplink),
|
||||
"downloadSpeed": Double(m.downlink),
|
||||
"urltestResults": latestUrltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] },
|
||||
]
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
lock.lock()
|
||||
latestUrltest = urltest
|
||||
latest["urltestResults"] = urltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] }
|
||||
lock.unlock()
|
||||
queue.async { [weak self] in self?.groupTags = tags }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,7 +659,7 @@
|
||||
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
@@ -674,7 +674,7 @@
|
||||
baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
@@ -689,7 +689,7 @@
|
||||
baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
@@ -960,7 +960,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -1010,7 +1010,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -1059,7 +1059,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@@ -1,105 +1,49 @@
|
||||
// StatsClient.swift — 主 app 侧实时统计生产者(macOS)
|
||||
// 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」骨架。
|
||||
// 隧道跑在 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 缓存)
|
||||
//
|
||||
// 字段映射到 pangolin/vpn/stats 契约(见 lib/bridge/vpn_bridge.dart),经 onStats 回调
|
||||
// 交给 VpnChannel 推到 Dart 侧。生命周期由 VpnChannel 的 NEVPNStatus 观察驱动。
|
||||
// 这绕开了 root/用户容器与 socket 属主的死局。字段映射到 pangolin/vpn/stats 契约
|
||||
// (见 lib/bridge/vpn_bridge.dart),经 onStats 交给 VpnChannel 推到 Dart 侧。
|
||||
|
||||
import Foundation
|
||||
import Libbox
|
||||
import NetworkExtension
|
||||
|
||||
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?
|
||||
private weak var session: NETunnelProviderSession?
|
||||
private var timer: DispatchSourceTimer?
|
||||
|
||||
// ── 生命周期 ────────────────────────────────────────────────
|
||||
|
||||
func start() {
|
||||
/// 隧道 connected 时调用,传入当前会话。每秒向扩展拉一次统计。
|
||||
func start(session: NETunnelProviderSession) {
|
||||
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
|
||||
}
|
||||
// 打点:command.sock 由 sysext 的 LibboxCommandServer 在 group 容器内创建。
|
||||
// 若此处 sock 不存在 → 跑的是没起 CommandServer 的旧 sysext(版本没换上)。
|
||||
if let base = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: self.appGroup) {
|
||||
let sock = base.appendingPathComponent("command.sock").path
|
||||
let exists = FileManager.default.fileExists(atPath: sock)
|
||||
NSLog("[pangolin/stats] command.sock=%@ exists=%@", sock, exists ? "YES" : "NO(sysext 未起 CommandServer/旧版)")
|
||||
}
|
||||
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")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/// 周期主动触发 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.timer?.cancel()
|
||||
self.timer = nil
|
||||
self.session = nil
|
||||
self.emit([
|
||||
"uploadBytes": 0, "downloadBytes": 0,
|
||||
"uploadSpeed": 0.0, "downloadSpeed": 0.0,
|
||||
@@ -108,71 +52,23 @@ final class StatsClient: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
||||
// ── 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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,10 +171,12 @@ final class VpnChannel: NSObject {
|
||||
let s = Self.statusString(conn.status)
|
||||
vpnLog("NEVPNStatus 变化 → \(s) (raw=\(conn.status.rawValue))")
|
||||
self.statusSink?(s)
|
||||
// 实时统计 CommandClient 跟随连接状态启停。
|
||||
// 实时统计跟随连接状态启停(经 sendProviderMessage 向扩展拉取)。
|
||||
switch conn.status {
|
||||
case .connected:
|
||||
self.statsClient.start()
|
||||
if let s = conn as? NETunnelProviderSession {
|
||||
self.statsClient.start(session: s)
|
||||
}
|
||||
case .disconnected, .invalid:
|
||||
self.statsClient.stop()
|
||||
default:
|
||||
@@ -193,10 +195,10 @@ final class VpnChannel: NSObject {
|
||||
let mgr = all.first else { return }
|
||||
await MainActor.run {
|
||||
self.manager = mgr
|
||||
if mgr.connection.status == .connected {
|
||||
if mgr.connection.status == .connected, let s = mgr.connection as? NETunnelProviderSession {
|
||||
vpnLog("启动时检测到隧道已连接(raw=\(mgr.connection.status.rawValue)),主动启动实时统计")
|
||||
self.statusSink?(Self.statusString(mgr.connection.status))
|
||||
self.statsClient.start()
|
||||
self.statsClient.start(session: s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,9 +211,12 @@ final class VpnChannel: NSObject {
|
||||
|
||||
fileprivate func onStatsListen(_ sink: @escaping FlutterEventSink) {
|
||||
statsSink = sink
|
||||
// 实时速率由 StatsClient(libbox CommandClient)在隧道 connected 时产出并经
|
||||
// onStats 回调推送;此处仅登记 sink。若已连接则补一次拉起。
|
||||
if manager?.connection.status == .connected { statsClient.start() }
|
||||
// 实时速率由 StatsClient(经 sendProviderMessage 向扩展拉取)在隧道 connected 时
|
||||
// 产出并经 onStats 回调推送;此处仅登记 sink。若已连接则补一次拉起。
|
||||
if manager?.connection.status == .connected,
|
||||
let s = manager?.connection as? NETunnelProviderSession {
|
||||
statsClient.start(session: s)
|
||||
}
|
||||
}
|
||||
fileprivate func onStatsCancel() { statsSink = nil }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user