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:
wangjia
2026-06-30 12:05:43 +08:00
parent 71132df669
commit 6c19b1bd92
4 changed files with 208 additions and 204 deletions
@@ -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;
+41 -145
View File
@@ -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 }
}
}
+12 -7
View File
@@ -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 }