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:
wangjia
2026-06-30 12:54:40 +08:00
parent 783687d7c2
commit 03dbbf8db9
3 changed files with 150 additions and 81 deletions
@@ -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;
+3 -2
View File
@@ -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)
}