fix(client/macos): urltest 延迟改走注入的 clash API(对齐 Windows),内核侧打点
诊断:即便专开 Group client,扩展回的 urltest 数组恒空——根因是服务端下发配置无 experimental.clash_api,sing-box 不暴露出站组/urltest 历史,libbox Group 命令拿不到。 修复(与 Windows 完全同法):扩展内向配置注入 clash_api(127.0.0.1 本地监听)+ cache_file, StatsCollector 经本地 HTTP 查 /proxies 找 URLTest 组、/group/<name>/delay 取真实延迟。 速率仍走 libbox Status。新增内核侧 NSLog 打点 kernel delay(<group>)=<tag:ms>,并把该值 塞进 stats 的 diag 字段经主 app 可读日志透出——一眼分清「内核有没有算出延迟」vs「外面没拿到」。 扩展代码变更 → CURRENT_PROJECT_VERSION 46→47。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let configContent = try self.resolveConfig(options)
|
||||
// urltest 延迟走 clash API:服务端下发的配置没有 clash_api,sing-box 不暴露
|
||||
// 出站组/urltest 历史 → libbox Group 命令返回空 → 连接页延迟为 —。这里在扩展内
|
||||
// 注入 clash_api(127.0.0.1 本地监听)+ cache_file,StatsCollector 经本地 HTTP
|
||||
// 查 /proxies + /group/<name>/delay 取真实延迟(与 Windows 同法)。
|
||||
let clashPort = Int.random(in: 20000...60000)
|
||||
let clashSecret = UUID().uuidString
|
||||
let configContent = Self.injectClashApi(try self.resolveConfig(options),
|
||||
port: clashPort, secret: clashSecret)
|
||||
|
||||
// libbox 数据目录用扩展自己的 App Group 容器(root 运行 → /var/root/...)。
|
||||
// ⚠️ 不要试图把它指向用户容器:扩展以 root 运行,command.sock 的目录/属主
|
||||
@@ -69,9 +76,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
|
||||
self.commandServer = server
|
||||
|
||||
// 进程内 stats 采集:连本进程自己的 command.sock(同容器,root 可达),
|
||||
// 缓存最新上/下行 + urltest,供 handleAppMessage 回给主 app。
|
||||
self.stats.start()
|
||||
// 进程内 stats 采集:libbox command.sock 取上/下行;clash API 取 urltest 延迟。
|
||||
self.stats.start(clashPort: clashPort, clashSecret: clashSecret)
|
||||
|
||||
log.info("startTunnel: service started")
|
||||
completionHandler(nil)
|
||||
@@ -108,6 +114,27 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 在 sing-box 配置里注入 experimental.clash_api(本地监听)+ cache_file,让内核暴露
|
||||
// 出站组/urltest 历史,供 StatsCollector 经本地 HTTP 取真实延迟。已有则不覆盖。
|
||||
static func injectClashApi(_ configJSON: String, port: Int, secret: String) -> String {
|
||||
guard var root = (try? JSONSerialization.jsonObject(with: Data(configJSON.utf8)))
|
||||
as? [String: Any] else { return configJSON }
|
||||
var experimental = (root["experimental"] as? [String: Any]) ?? [:]
|
||||
if experimental["clash_api"] == nil {
|
||||
experimental["clash_api"] = [
|
||||
"external_controller": "127.0.0.1:\(port)",
|
||||
"secret": secret,
|
||||
]
|
||||
}
|
||||
if experimental["cache_file"] == nil {
|
||||
experimental["cache_file"] = ["enabled": true]
|
||||
}
|
||||
root["experimental"] = experimental
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: root),
|
||||
let out = String(data: data, encoding: .utf8) else { return configJSON }
|
||||
return out
|
||||
}
|
||||
|
||||
private func resolveConfig(_ options: [String: NSObject]?) throws -> String {
|
||||
if let inline = options?["configContent"] as? String, !inline.isEmpty {
|
||||
return inline
|
||||
@@ -338,24 +365,26 @@ 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 的死局。
|
||||
// ── StatsCollector:扩展进程内统计采集 ──────────────────────────────────────
|
||||
// 上/下行速率:连本进程自己的 command.sock(libbox Status 命令)。
|
||||
// urltest 延迟:经注入的 clash API(本地 HTTP)查 /proxies + /group/<name>/delay——
|
||||
// 服务端配置无 clash_api,sing-box 不暴露组/urltest 历史,libbox Group 命令返回空;
|
||||
// 注入 clash_api 后用与 Windows 完全一致的取法(getGroupDelay)拿内核实测延迟。
|
||||
// 缓存的 latest 由 handleAppMessage 回给主 app。
|
||||
final class StatsCollector: NSObject {
|
||||
private let queue = DispatchQueue(label: "pangolin.stats.collector")
|
||||
// ⚠️ libbox 的 command 协议是「一连接一命令」:一个 client 只订阅一种命令。把 Status 与
|
||||
// Group addCommand 到同一 client,只有第一种(Status)生效,Group 永不回调 → urltest 为空、
|
||||
// 连接页延迟显示 —。对齐 sing-box-for-apple:每种命令各开一个 client。
|
||||
private var statusClient: LibboxCommandClient?
|
||||
private var groupClient: LibboxCommandClient?
|
||||
private var started = false
|
||||
private var urlTestTimer: DispatchSourceTimer?
|
||||
private var clashTimer: DispatchSourceTimer?
|
||||
|
||||
// clash API(扩展内注入,127.0.0.1 本地监听):取出站组 urltest 真实延迟。
|
||||
private var clashBase = ""
|
||||
private var clashSecret = ""
|
||||
private var groupNames: [String] = []
|
||||
|
||||
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,
|
||||
@@ -363,52 +392,42 @@ final class StatsCollector: NSObject {
|
||||
"urltestResults": [[String: Any]](),
|
||||
]
|
||||
|
||||
func start() {
|
||||
func start(clashPort: Int, clashSecret: String) {
|
||||
queue.async { [weak self] in
|
||||
guard let self, !self.started else { return }
|
||||
self.statusClient = self.connectClient(command: LibboxCommandStatus, label: "status")
|
||||
self.groupClient = self.connectClient(command: LibboxCommandGroup, label: "group")
|
||||
guard self.statusClient != nil || self.groupClient != nil else { return }
|
||||
self.clashBase = "http://127.0.0.1:\(clashPort)"
|
||||
self.clashSecret = clashSecret
|
||||
self.statusClient = self.connectStatusClient()
|
||||
self.started = true
|
||||
self.startUrlTestTimer()
|
||||
self.startClashTimer()
|
||||
}
|
||||
}
|
||||
|
||||
/// 为单一命令开一个 client 并连上(command.sock 刚建好可能稍滞后,重试)。
|
||||
private func connectClient(command: Int32, label: String) -> LibboxCommandClient? {
|
||||
/// libbox Status client(取速率)。command.sock 刚建好可能稍滞后,重试。
|
||||
private func connectStatusClient() -> LibboxCommandClient? {
|
||||
let options = LibboxCommandClientOptions()
|
||||
options.statusInterval = 1_000_000_000 // 1s
|
||||
options.addCommand(command)
|
||||
guard let c = LibboxNewCommandClient(self, options) else {
|
||||
NSLog("[pangolin/collector] %@ LibboxNewCommandClient nil", label)
|
||||
return nil
|
||||
}
|
||||
options.addCommand(LibboxCommandStatus)
|
||||
guard let c = LibboxNewCommandClient(self, options) else { return nil }
|
||||
for _ in 0..<20 {
|
||||
do {
|
||||
try c.connect()
|
||||
NSLog("[pangolin/collector] %@ connected", label)
|
||||
return c
|
||||
} catch {
|
||||
Thread.sleep(forTimeInterval: 0.25)
|
||||
}
|
||||
do { try c.connect(); NSLog("[pangolin/collector] status connected"); return c }
|
||||
catch { Thread.sleep(forTimeInterval: 0.25) }
|
||||
}
|
||||
NSLog("[pangolin/collector] %@ gave up connecting", label)
|
||||
NSLog("[pangolin/collector] status gave up")
|
||||
return nil
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.urlTestTimer?.cancel(); self.urlTestTimer = nil
|
||||
self.clashTimer?.cancel(); self.clashTimer = nil
|
||||
if let c = self.statusClient { try? c.disconnect() }
|
||||
if let c = self.groupClient { try? c.disconnect() }
|
||||
self.statusClient = nil
|
||||
self.groupClient = nil
|
||||
self.started = false
|
||||
self.groupNames = []
|
||||
self.lock.lock()
|
||||
self.latest = StatsCollector.zero
|
||||
self.latestUrltest = []
|
||||
self.groupTags = []
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -418,17 +437,86 @@ final class StatsCollector: NSObject {
|
||||
return (try? JSONSerialization.data(withJSONObject: snap)) ?? Data("{}".utf8)
|
||||
}
|
||||
|
||||
private func startUrlTestTimer() {
|
||||
urlTestTimer?.cancel()
|
||||
// ── clash API 轮询:取 urltest 延迟(与 Windows getGroupDelay 同法)─────────
|
||||
private func startClashTimer() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 2, repeating: 10)
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self, let c = self.groupClient else { return }
|
||||
// groupTags 由 writeGroups 回调填充;主动触发各组 urltest,补偿 sing-box 惰性探测。
|
||||
for tag in self.groupTags { try? c.urlTest(tag) }
|
||||
}
|
||||
timer.schedule(deadline: .now() + 3, repeating: 8)
|
||||
timer.setEventHandler { [weak self] in self?.pollClash() }
|
||||
timer.resume()
|
||||
urlTestTimer = timer
|
||||
clashTimer = timer
|
||||
}
|
||||
|
||||
private func pollClash() {
|
||||
if groupNames.isEmpty {
|
||||
clashGet("/proxies", query: nil) { [weak self] obj in
|
||||
guard let self else { return }
|
||||
let names = StatsCollector.extractUrltestGroups(obj)
|
||||
self.queue.async {
|
||||
self.groupNames = names
|
||||
self.setDiag("groups=\(names)")
|
||||
self.triggerGroupDelays()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
triggerGroupDelays()
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerGroupDelays() {
|
||||
for name in groupNames {
|
||||
clashGet("/group/\(name)/delay",
|
||||
query: ["url": "http://www.gstatic.com/generate_204", "timeout": "3000"]) { [weak self] obj in
|
||||
guard let self, let dict = obj as? [String: Any] else { return }
|
||||
var urltest: [(tag: String, delayMs: Int)] = []
|
||||
for (tag, v) in dict {
|
||||
if let d = (v as? NSNumber)?.intValue { urltest.append((tag: tag, delayMs: d)) }
|
||||
}
|
||||
let summary = urltest.map { "\($0.tag):\($0.delayMs)" }.joined(separator: ",")
|
||||
NSLog("[pangolin/collector] kernel delay(%@)=%@", name, summary) // 内核侧实测打点
|
||||
self.queue.async {
|
||||
self.lock.lock()
|
||||
self.latestUrltest = urltest
|
||||
self.latest["urltestResults"] = urltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] }
|
||||
self.latest["diag"] = "delay(\(name))=\(summary)"
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setDiag(_ s: String) {
|
||||
lock.lock(); latest["diag"] = s; lock.unlock()
|
||||
}
|
||||
|
||||
private func clashGet(_ path: String, query: [String: String]?,
|
||||
completion: @escaping (Any?) -> Void) {
|
||||
guard var comps = URLComponents(string: clashBase + path) else { completion(nil); return }
|
||||
if let query { comps.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } }
|
||||
guard let url = comps.url else { completion(nil); return }
|
||||
var req = URLRequest(url: url)
|
||||
req.timeoutInterval = 8
|
||||
if !clashSecret.isEmpty {
|
||||
req.setValue("Bearer \(clashSecret)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
URLSession.shared.dataTask(with: req) { data, _, err in
|
||||
if let err { NSLog("[pangolin/collector] clash %@ err=%@", path, err.localizedDescription) }
|
||||
guard let data else { completion(nil); return }
|
||||
completion(try? JSONSerialization.jsonObject(with: data))
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// 从 /proxies 提取 URLTest/Fallback 组名(clash 约定类型名首字母大写)。
|
||||
static func extractUrltestGroups(_ obj: Any?) -> [String] {
|
||||
guard let root = obj as? [String: Any],
|
||||
let proxies = root["proxies"] as? [String: Any] else { return [] }
|
||||
var names: [String] = []
|
||||
for (name, v) in proxies {
|
||||
if let m = v as? [String: Any], let type = m["type"] as? String,
|
||||
type == "URLTest" || type == "Fallback" {
|
||||
names.append(name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,34 +532,14 @@ extension StatsCollector: LibboxCommandClientHandlerProtocol {
|
||||
|
||||
func writeStatus(_ message: LibboxStatusMessage?) {
|
||||
guard let m = message else { return }
|
||||
// 逐键更新(不整盘重建),保留 clash 轮询写入的 urltestResults / diag。
|
||||
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] },
|
||||
]
|
||||
latest["uploadBytes"] = m.uplinkTotal
|
||||
latest["downloadBytes"] = m.downlinkTotal
|
||||
latest["uploadSpeed"] = Double(m.uplink)
|
||||
latest["downloadSpeed"] = Double(m.downlink)
|
||||
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 }
|
||||
}
|
||||
func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {}
|
||||
}
|
||||
|
||||
@@ -659,7 +659,7 @@
|
||||
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
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 = 46;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
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 = 46;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
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 = 46;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
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 = 46;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
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 = 46;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@@ -67,8 +67,9 @@ final class StatsClient: NSObject {
|
||||
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]=%@",
|
||||
"\(obj["downloadSpeed"] ?? 0)", "\(obj["uploadSpeed"] ?? 0)", urls.count, pairs)
|
||||
NSLog("[pangolin/stats] dl=%@ ul=%@ urltest[%d]=%@ diag=%@",
|
||||
"\(obj["downloadSpeed"] ?? 0)", "\(obj["uploadSpeed"] ?? 0)", urls.count, pairs,
|
||||
"\(obj["diag"] ?? "-")")
|
||||
}
|
||||
self.emit(obj)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user