feat(client/ios): 实时统计+urltest 延迟与 macOS 对齐 + 修 pbxproj UUID 冲突

把 macOS 已验证的统计架构移植到 iOS,补齐连接页延迟(此前 iOS 同 macOS 根因:扩展没注入
clash_api + app 单 client Status+Group → urltest 恒空):
- 扩展 PacketTunnelProvider:加 injectClashApi(clash_api+cache_file)+ 进程内 StatsCollector
  (libbox 取速率 + clash /proxies、/group/delay 取 urltest);handleAppMessage 增 action="stats"。
- app StatsClient:改为每秒 sendProviderMessage("stats") 拉取 → VpnStatsStreamHandler 推送,
  去掉直连 socket 的单 client Status+Group 坏写法。

修两处编译阻断(框架补齐后才暴露,此前 iOS 从未编译到这步):
- pbxproj 致命 bug:StatsClient.swift 与 PacketTunnelProvider.swift 复用同一 UUID
  (...0002...AA/AB),Xcode 无法区分 → 文件错配 target、全模块 cannot-find 级联。
  PacketTunnelProvider.swift 改用独立 ...0007... ID。
- tunFd 的 CTLIOCGINFO 单行 | 链拆成子表达式,避免 Swift 类型检查器超时。

前置:scripts/build-libbox.sh apple ios 生成 client/ios/Frameworks/Libbox.xcframework(gitignore)。
Dart 侧无需改(iOS 同走 VpnNativeBridge,本轮 macOS 的广播流/_onStats 回退/连接态跳直连实测已共享)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-30 19:12:28 +08:00
parent 7add0259b4
commit ae5d497f0b
3 changed files with 271 additions and 153 deletions
@@ -23,6 +23,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
private var commandServer: LibboxCommandServer?
private var platform: PangolinPlatformInterface?
private let memoryMonitor = MemoryMonitor() // iOS
private let stats = StatsCollector() // (libbox)+ urltest(clash API)
override func startTunnel(options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void) {
@@ -33,7 +34,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 clash_api(127.0.0.1
// )+ cache_file,StatsCollector HTTP /proxies + /group/<name>/delay
// ( macOS/Windows )
let clashPort = Int.random(in: 20000...60000)
let clashSecret = UUID().uuidString
let configContent = Self.injectClashApi(try self.resolveConfig(options),
port: clashPort, secret: clashSecret)
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
@@ -63,6 +71,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
self.commandServer = server
self.memoryMonitor.start() // iOS
// stats :libbox command.sock /;clash API urltest
self.stats.start(clashPort: clashPort, clashSecret: clashSecret)
log.info("startTunnel: service started")
completionHandler(nil)
} catch {
@@ -75,6 +85,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
override func stopTunnel(with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void) {
log.info("stopTunnel reason=\(reason.rawValue)")
stats.stop()
memoryMonitor.stop() // iOS
log.info("memory peak: \(self.memoryMonitor.summaryString(), privacy: .public)") // iOS
try? commandServer?.closeService()
@@ -89,6 +100,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
completionHandler: ((Data?) -> Void)?) {
guard let req = try? JSONSerialization.jsonObject(with: messageData) as? [String: Any],
let action = req["action"] as? String else { completionHandler?(nil); return }
// App : StatsCollector JSON( pangolin/vpn/stats)
if action == "stats" { completionHandler?(stats.latestJSON()); return }
var resp: [String: Any] = [:]
switch action {
case "getStatus":
@@ -102,6 +115,27 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
completionHandler?((try? JSONSerialization.data(withJSONObject: resp)) ?? nil)
}
// 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?["config"] as? String, !inline.isEmpty {
return inline
@@ -285,10 +319,13 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
strncpy(ptr.baseAddress!.assumingMemoryBound(to: CChar.self), $0, ptr.count - 1)
}
}
// CTLIOCGINFO = _IOWR('N', 3, struct ctl_info)(Swift ,)
let ctliocginfo: UInt = 0x8000_0000 | 0x4000_0000
| ((UInt(MemoryLayout<ctl_info>.stride) & 0x1fff) << 16)
| (UInt(UInt8(ascii: "N")) << 8) | 3
// CTLIOCGINFO = _IOWR('N', 3, struct ctl_info)(Swift ,)
// : | Swift ( "unable to type-check
// in reasonable time"), ctl_info/sockaddr_ctl not in scope
let iocInOut: UInt = 0x8000_0000 | 0x4000_0000
let lenField: UInt = (UInt(MemoryLayout<ctl_info>.stride) & 0x1fff) << 16
let groupField: UInt = UInt(UInt8(ascii: "N")) << 8
let ctliocginfo: UInt = iocInOut | lenField | groupField | 3
for fd in Int32(0)...1024 {
var addr = sockaddr_ctl()
var len = socklen_t(MemoryLayout<sockaddr_ctl>.size)
@@ -340,3 +377,201 @@ final class InterfaceIterator: NSObject, LibboxNetworkInterfaceIteratorProtocol
private func simpleError(_ msg: String) -> NSError {
NSError(domain: "pangolin.tunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
}
// StatsCollector:( macOS )
// /: command.sock(libbox Status )
// urltest : clash API( HTTP) /proxies + /group/<name>/delay
// latest handleAppMessage(action=="stats") App
final class StatsCollector: NSObject {
private let queue = DispatchQueue(label: "pangolin.stats.collector")
private var statusClient: LibboxCommandClient?
private var started = false
private var clashTimer: DispatchSourceTimer?
private var clashBase = ""
private var clashSecret = ""
private let lock = NSLock()
private var latest: [String: Any] = StatsCollector.zero
private var latestUrltest: [(tag: String, delayMs: Int)] = []
private static let zero: [String: Any] = [
"uploadBytes": 0, "downloadBytes": 0,
"uploadSpeed": 0.0, "downloadSpeed": 0.0,
"urltestResults": [[String: Any]](),
]
func start(clashPort: Int, clashSecret: String) {
queue.async { [weak self] in
guard let self, !self.started else { return }
self.clashBase = "http://127.0.0.1:\(clashPort)"
self.clashSecret = clashSecret
self.statusClient = self.connectCommand(LibboxCommandStatus, "status")
self.started = true
self.startClashTimer()
}
}
/// libbox client command.sock ,
private func connectCommand(_ command: Int32, _ label: String) -> LibboxCommandClient? {
let options = LibboxCommandClientOptions()
options.statusInterval = 1_000_000_000 // 1s
options.addCommand(command)
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) }
}
NSLog("[pangolin/collector] %@ gave up", label)
return nil
}
func stop() {
queue.async { [weak self] in
guard let self else { return }
self.clashTimer?.cancel(); self.clashTimer = nil
if let c = self.statusClient { try? c.disconnect() }
self.statusClient = nil
self.started = false
self.lock.lock()
self.latest = StatsCollector.zero
self.latestUrltest = []
self.lock.unlock()
}
}
func latestJSON() -> Data {
lock.lock(); let snap = latest; lock.unlock()
return (try? JSONSerialization.data(withJSONObject: snap)) ?? Data("{}".utf8)
}
// clash API : urltest
private func startClashTimer() {
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 3, repeating: 8)
timer.setEventHandler { [weak self] in self?.pollClash() }
timer.resume()
clashTimer = timer
}
// = /proxies URLTest history.last.delay(,,);
// /group/<name>/delay history,()
private func pollClash() {
clashGet("/proxies") { [weak self] obj in
guard let self else { return }
let hist = StatsCollector.extractUrltestResults(obj)
let groups = StatsCollector.extractUrltestGroups(obj).filter { $0 != "GLOBAL" }
self.queue.async {
self.cacheUrltest(hist)
for name in groups {
self.clashGet("/group/\(name)/delay",
query: ["url": "http://www.gstatic.com/generate_204",
"timeout": "5000"]) { [weak self] dobj in
guard let self else { return }
let active = StatsCollector.parseDelayMap(dobj)
self.queue.async { self.cacheUrltest(active) }
}
}
}
}
}
/// ( history active ),/
private func cacheUrltest(_ list: [(tag: String, delayMs: Int)]) {
guard !list.isEmpty else { return }
lock.lock()
latestUrltest = list
latest["urltestResults"] = list.map { ["tag": $0.tag, "delayMs": $0.delayMs] }
lock.unlock()
}
private func clashGet(_ path: String, query: [String: String]? = nil,
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, _, _ in
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
}
/// /proxies URLTest/Fallback history
static func extractUrltestResults(_ obj: Any?) -> [(tag: String, delayMs: Int)] {
guard let root = obj as? [String: Any],
let proxies = root["proxies"] as? [String: Any] else { return [] }
var results: [(tag: String, delayMs: Int)] = []
for (_, v) in proxies {
guard let group = v as? [String: Any],
let type = group["type"] as? String, type == "URLTest" || type == "Fallback",
let members = group["all"] as? [Any] else { continue }
for m in members {
guard let tag = m as? String,
let member = proxies[tag] as? [String: Any],
let history = member["history"] as? [Any], !history.isEmpty,
let last = history.last as? [String: Any],
let delay = (last["delay"] as? NSNumber)?.intValue, delay > 0 else { continue }
results.append((tag: tag, delayMs: delay))
}
}
return results
}
/// /group/<name>/delay {tag: ms} ( {tag:{delay:ms}}),
static func parseDelayMap(_ obj: Any?) -> [(tag: String, delayMs: Int)] {
guard let dict = obj as? [String: Any] else { return [] }
var out: [(tag: String, delayMs: Int)] = []
for (tag, v) in dict {
if let d = (v as? NSNumber)?.intValue, d > 0 {
out.append((tag: tag, delayMs: d))
} else if let inner = v as? [String: Any],
let d = (inner["delay"] as? NSNumber)?.intValue, d > 0 {
out.append((tag: tag, delayMs: d))
}
}
return out
}
}
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 }
// (), clash urltestResults
lock.lock()
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)?) {}
}
+4 -4
View File
@@ -16,7 +16,7 @@
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
CAFE000000000001000000AB /* VpnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000001000000AA /* VpnManager.swift */; };
CAFE000000000002000000AB /* StatsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000002000000AA /* StatsClient.swift */; };
CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000002000000AA /* PacketTunnelProvider.swift */; };
CAFE000000000007000000AB /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000007000000AA /* PacketTunnelProvider.swift */; };
CAFE000000000004000000AB /* PacketTunnel.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CAFE000000000004000000AA /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000006000000AB /* MemoryMonitor.swift */; };
CAFE000000000081000000AB /* Libbox.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = CAFE000000000081000000AA /* Libbox.xcframework */; };
@@ -80,7 +80,7 @@
C890E0E50FB9E2909B5EA7B8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
CAFE000000000001000000AA /* VpnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VpnManager.swift; sourceTree = "<group>"; };
CAFE000000000002000000AA /* StatsClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsClient.swift; sourceTree = "<group>"; };
CAFE000000000002000000AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
CAFE000000000007000000AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
CAFE000000000003000000AA /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CAFE000000000004000000AA /* PacketTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; };
CAFE000000000005000000AA /* Runner.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
@@ -203,7 +203,7 @@
CAFE000000000020000000AA /* PacketTunnel */ = {
isa = PBXGroup;
children = (
CAFE000000000002000000AA /* PacketTunnelProvider.swift */,
CAFE000000000007000000AA /* PacketTunnelProvider.swift */,
CAFE000000000006000000AB /* MemoryMonitor.swift */,
CAFE000000000006000000AA /* PacketTunnel.entitlements */,
CAFE000000000003000000AA /* Info.plist */,
@@ -408,7 +408,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */,
CAFE000000000007000000AB /* PacketTunnelProvider.swift in Sources */,
CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
+27 -144
View File
@@ -1,108 +1,47 @@
// StatsClient.swift App
// StatsClient.swift App iOS
//
// iOS PacketTunnel LibboxSetup App Group
// LibboxCommandServer/ + App LibboxCommandClient
// group CommandServer socket status/group
// sing-box-for-apple(SFI) Android StatsHandler
// PacketTunnel StatsCollector /(libbox command.sock)
// + urltest ( clash API) JSON App
// NETunnelProviderSession.sendProviderMessage(action="stats"),
// pangolin/vpn/stats EventChannel macOS (app NE )
//
// CommandServer statusInterval client 广 box client
// writeStatus/writeGroups pangolin/vpn/stats
// ( lib/bridge/vpn_bridge.dart) VpnStatsStreamHandler Dart
// ( App LibboxCommandClient socket): client
// Status+Group Group (libbox ); clash_api
// urltest StatsCollector + sendProviderMessage , macOS
//
// VPN connected start()/ stop() VpnManager
import Foundation
import Libbox
/// LibboxCommandClient stats EventChannel
final class StatsClient: NSObject {
static let shared = StatsClient()
// iOS App Group PacketTunnelProvider setup
private let appGroup = "group.com.pangolin.pangolinVpn"
private let queue = DispatchQueue(label: "pangolin.stats.client")
private var client: LibboxCommandClient?
private var started = false
// libbox setup
private static var didSetup = false
/// writeGroups urltest (tag delayMs)
/// writeStatus ( Androidstatus group )
private var latestUrltest: [(tag: String, delayMs: Int)] = []
/// writeGroups tag urltest "auto"
private var groupTags: [String] = []
/// urltestsing-box urltest ( interval history )
/// 12s
private var urlTestTimer: DispatchSourceTimer?
private var timer: DispatchSourceTimer?
//
/// CommandClient
///
func start() {
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) // urltest
guard let c = LibboxNewCommandClient(self, options) else {
NSLog("StatsClient: LibboxNewCommandClient returned nil")
return
}
// CommandServer ready
for attempt in 0..<10 {
do {
try c.connect()
self.client = c
self.started = true
NSLog("StatsClient: connected (attempt %d)", attempt + 1)
self.startUrlTestTimer()
return
} catch {
NSLog("StatsClient: connect failed (attempt %d): %@", attempt + 1,
error.localizedDescription)
Thread.sleep(forTimeInterval: 0.5)
}
}
NSLog("StatsClient: gave up connecting after retries")
guard let self else { return }
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("StatsClient: polling via sendProviderMessage started")
}
}
/// CommandClient
/// 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
}
/// UI
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 = []
// UI
self.timer?.cancel()
self.timer = nil
VpnStatsStreamHandler.shared.push([
"uploadBytes": 0, "downloadBytes": 0,
"uploadSpeed": 0.0, "downloadSpeed": 0.0,
@@ -111,68 +50,12 @@ final class StatsClient: NSObject {
}
}
// libbox setup group
private func ensureSetup() -> Bool {
if Self.didSetup { return true }
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
NSLog("StatsClient: no app group container")
return false
private func poll() {
let req = (try? JSONSerialization.data(withJSONObject: ["action": "stats"])) ?? Data()
VpnManager.shared.sendProviderMessage(req) { resp, _ in
guard let resp, !resp.isEmpty,
let obj = (try? JSONSerialization.jsonObject(with: resp)) as? [String: Any] else { return }
VpnStatsStreamHandler.shared.push(obj)
}
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("StatsClient: LibboxSetup failed: %@", err.localizedDescription)
return false
}
Self.didSetup = true
return true
}
}
// LibboxCommandClientHandler
extension StatsClient: LibboxCommandClientHandlerProtocol {
func connected() { NSLog("StatsClient: server connected") }
func disconnected(_ message: String?) { NSLog("StatsClient: 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 }
let stats: [String: Any] = [
"uploadBytes": m.uplinkTotal,
"downloadBytes": m.downlinkTotal,
"uploadSpeed": Double(m.uplink),
"downloadSpeed": Double(m.downlink),
"urltestResults": latestUrltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] },
]
VpnStatsStreamHandler.shared.push(stats)
}
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 }
}
}