Files
pangolin/client/ios/PacketTunnel/PacketTunnelProvider.swift
wangjia e1e43adc80
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 26s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 21s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 2m5s
ci-pangolin / Go — build + test (push) Failing after 2m37s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 3m47s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Failing after 11m31s
ci-pangolin / Flutter — analyze + test (push) Failing after 11m39s
ci-pangolin / OpenAPI Sync Check (push) Failing after 11m50s
ci-pangolin / Lint — shellcheck (push) Failing after 12m1s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 6m57s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 10m29s
Deploy Client / build-windows (push) Successful in 1m51s
Deploy Client / build-android (push) Failing after 3m7s
Deploy Client / release-deploy (push) Has been skipped
Deploy Client / build-macos (push) Failing after 27s
Deploy Client / build-ios (push) Failing after 5m17s
fix(client-tun): macOS/iOS 应用 excludedRoutes,私有 LAN 真直连
上一版加了服务端 route_exclude_address,但 macOS/iOS 路由由 Swift 的
NEPacketTunnelNetworkSettings 控制,而 openTun 只设了 includedRoutes、
从不设 excludedRoutes → libbox 解析出的排除网段被丢弃,strict_route 仍把
LAN(192.168/16、10/8)抓进隧道,VPN 开着连不上局域网/NAS/家里机器/内网 gitea。

修:openTun 里 ipv4/ipv6 各补 excludedRoutes = excludedRoutes4/6(options),
读 libbox 的 getInet4/6RouteExcludeAddress(sing-box config 的 route_exclude_address
经此暴露)。镜像已有的 includedRoutes4/6 写法。

生效:客户端重新构建发版后,私有 LAN 直连恢复 → CI runner 不再需要经 ali 的
中继隧道(relay),从 Mac 也能直连家里所有内网机器。
Android(VpnService.excludeRoute,API33+ 分版本)作后续。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 07:17:09 +08:00

593 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PacketTunnelProvider.swift Pangolin iOS
//
// NEPacketTunnelProvider , sing-box(libbox) TUN, root
// macOS (LibboxNewCommandServer + LibboxPlatformInterfaceProtocol
// + utun fd)
//
// gomobile : Go protocol + class;Swift `XxxProtocol`,
// / classNE override completion-handler( Swift 6.2
// asyncObjC thunk )
import Darwin
import Foundation
import Libbox
import Network
import NetworkExtension
import os
private let log = Logger(subsystem: "com.pangolin.pangolinVpn.PacketTunnel", category: "provider")
// iOS App Group group. macOS <TeamID>.
private let appGroup = "group.com.pangolin.pangolinVpn"
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) {
log.info("startTunnel")
// libbox :startOrReloadService openTun
// setTunnelNetworkSettings, provider ; provider
// (NE startTunnel ) startOrReloadService
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
do {
// 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 {
throw simpleError("no app group container")
}
let work = base.appendingPathComponent("work", isDirectory: true)
try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
let setup = LibboxSetupOptions()
setup.basePath = base.path
setup.workingPath = work.path
setup.tempPath = NSTemporaryDirectory()
var setupErr: NSError?
LibboxSetup(setup, &setupErr)
if let setupErr { throw setupErr }
let platform = PangolinPlatformInterface(provider: self)
self.platform = platform
var newErr: NSError?
guard let server = LibboxNewCommandServer(self, platform, &newErr) else {
throw newErr ?? simpleError("LibboxNewCommandServer returned nil")
}
try server.start()
// options: libbox StartOrReloadService options,
// nil command_server.go:175 SIGSEGV()
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 {
log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)")
completionHandler(error)
}
}
}
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()
commandServer?.close()
commandServer = nil
platform?.stopMonitor()
platform = nil
completionHandler()
}
override func handleAppMessage(_ messageData: Data,
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":
resp["status"] = (commandServer != nil) ? "on" : "error"
case "getMemory":
resp["memory"] = memoryMonitor.summaryString()
resp["peakRssBytes"] = memoryMonitor.peakResidentBytes
default:
resp["error"] = "unknown action: \(action)"
}
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
}
if let shared = UserDefaults(suiteName: appGroup),
let cached = shared.string(forKey: "lastConfigJson"), !cached.isEmpty {
return cached
}
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
throw simpleError("no app group container")
}
let content = (try? String(contentsOf: base.appendingPathComponent("config.json"),
encoding: .utf8)) ?? ""
guard !content.isEmpty else { throw simpleError("empty sing-box config") }
return content
}
}
// LibboxCommandServerHandler:
extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol {
func serviceReload() throws {
if let cfg = try? resolveConfig(nil) {
try commandServer?.startOrReloadService(cfg, options: LibboxOverrideOptions())
}
}
func serviceStop() throws { cancelTunnelWithError(nil) }
func postServiceClose() {}
func getSystemProxyStatus() throws -> LibboxSystemProxyStatus {
let s = LibboxSystemProxyStatus(); s.available = false; s.enabled = false; return s
}
func setSystemProxyEnabled(_ enabled: Bool) throws {}
func writeStatus(_ message: LibboxStatusMessage?) {}
func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {}
func writeConnectionEvents(_ events: LibboxConnectionEvents?) {}
func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {}
func writeDebugMessage(_ message: String?) {}
func setDefaultLogLevel(_ level: Int32) {}
func initializeClashMode(_ modeList: (any LibboxStringIteratorProtocol)?, currentMode: String?) {}
func updateClashMode(_ newMode: String?) {}
func clearLogs() {}
func connected() {}
func disconnected(_ message: String?) {}
}
// libbox NetworkExtension (LibboxPlatformInterface)
final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol {
private weak var provider: NEPacketTunnelProvider?
private var monitor: NWPathMonitor?
private var defaultInterfaceIndex: Int32 = -1
private let monitorQueue = DispatchQueue(label: "pangolin.tunnel.pathmonitor")
init(provider: NEPacketTunnelProvider) {
self.provider = provider
super.init()
}
func stopMonitor() { monitor?.cancel(); monitor = nil }
// MARK: openTun TunOptions NEPacketTunnelNetworkSettings, utun fd
func openTun(_ options: (any LibboxTunOptionsProtocol)?, ret0_: UnsafeMutablePointer<Int32>?) throws {
guard let options, let provider else { throw simpleError("openTun: no options/provider") }
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
settings.mtu = NSNumber(value: options.getMTU())
let v4addrs = routePrefixes(options.getInet4Address())
if !v4addrs.isEmpty {
let ipv4 = NEIPv4Settings(addresses: v4addrs.map(\.address),
subnetMasks: v4addrs.map(\.mask))
ipv4.includedRoutes = includedRoutes4(options)
ipv4.excludedRoutes = excludedRoutes4(options)
settings.ipv4Settings = ipv4
}
let v6addrs = routePrefixes(options.getInet6Address())
if !v6addrs.isEmpty {
let ipv6 = NEIPv6Settings(addresses: v6addrs.map(\.address),
networkPrefixLengths: v6addrs.map { NSNumber(value: $0.prefix) })
ipv6.includedRoutes = includedRoutes6(options)
ipv6.excludedRoutes = excludedRoutes6(options)
settings.ipv6Settings = ipv6
}
if let dns = try? options.getDNSServerAddress(), !dns.value.isEmpty {
let dnsSettings = NEDNSSettings(servers: [dns.value])
// iOS matchDomains=[""] DNS ;
// DNS , DNS(en0),
// DNS "" =
dnsSettings.matchDomains = [""]
settings.dnsSettings = dnsSettings
}
let sem = DispatchSemaphore(value: 0)
var applyError: Error?
provider.setTunnelNetworkSettings(settings) { err in applyError = err; sem.signal() }
sem.wait()
if let applyError { throw applyError }
guard let fd = Self.tunFd() else { throw simpleError("openTun: utun fd not found") }
ret0_?.pointee = fd
}
// MARK:
func startDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws {
let m = NWPathMonitor()
monitor = m
// path : sing-box rule-set
// defaultInterfaceIndex -1,autoDetectControl "no available
// network interface" sing-box-for-apple
let firstUpdate = DispatchSemaphore(value: 0)
var signaled = false
m.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let iface = path.availableInterfaces.first { path.usesInterfaceType($0.type) }
?? path.availableInterfaces.first
let name = iface?.name ?? ""
let index = Int32(iface?.index ?? 0)
self.defaultInterfaceIndex = index
listener?.updateDefaultInterface(name, interfaceIndex: index,
isExpensive: path.isExpensive,
isConstrained: path.isConstrained)
if !signaled { signaled = true; firstUpdate.signal() }
}
m.start(queue: monitorQueue)
_ = firstUpdate.wait(timeout: .now() + 5)
}
func closeDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws {
stopMonitor()
}
// MARK: socket
func autoDetectControl(_ fd: Int32) throws {
guard defaultInterfaceIndex > 0 else { return }
var idx = defaultInterfaceIndex
_ = setsockopt(fd, IPPROTO_IP, IP_BOUND_IF, &idx, socklen_t(MemoryLayout<Int32>.size))
_ = setsockopt(fd, IPPROTO_IPV6, IPV6_BOUND_IF, &idx, socklen_t(MemoryLayout<Int32>.size))
}
func usePlatformAutoDetectControl() -> Bool { true }
// MARK:
func getInterfaces() throws -> LibboxNetworkInterfaceIteratorProtocol {
InterfaceIterator(items: Self.enumerateInterfaces())
}
// MARK: ()
func underNetworkExtension() -> Bool { true }
func useProcFS() -> Bool { false }
func includeAllNetworks() -> Bool { false }
func clearDNSCache() {}
func readWIFIState() -> LibboxWIFIState? { nil }
func systemCertificates() -> (any LibboxStringIteratorProtocol)? { nil }
func localDNSTransport() -> (any LibboxLocalDNSTransportProtocol)? { nil }
func send(_ notification: LibboxNotification?) throws {}
func findConnectionOwner(_ ipProtocol: Int32, sourceAddress: String?, sourcePort: Int32,
destinationAddress: String?, destinationPort: Int32) throws -> LibboxConnectionOwner {
throw simpleError("findConnectionOwner: unsupported")
}
//
private func routePrefixes(_ it: (any LibboxRoutePrefixIteratorProtocol)?) -> [(address: String, mask: String, prefix: Int32)] {
var out: [(address: String, mask: String, prefix: Int32)] = []
guard let it else { return out }
while it.hasNext() {
if let p = it.next() { out.append((address: p.address(), mask: p.mask(), prefix: p.prefix())) }
}
return out
}
private func includedRoutes4(_ o: any LibboxTunOptionsProtocol) -> [NEIPv4Route] {
let r = routePrefixes(o.getInet4RouteAddress())
if r.isEmpty { return [NEIPv4Route.default()] }
return r.map { NEIPv4Route(destinationAddress: $0.address, subnetMask: $0.mask) }
}
private func includedRoutes6(_ o: any LibboxTunOptionsProtocol) -> [NEIPv6Route] {
let r = routePrefixes(o.getInet6RouteAddress())
if r.isEmpty { return [NEIPv6Route.default()] }
return r.map { NEIPv6Route(destinationAddress: $0.address,
networkPrefixLength: NSNumber(value: $0.prefix)) }
}
// :sing-box config route_exclude_address( LAN ) libbox
// includedRoutes excludedRoutes strict_route LAN
// ,VPN /NAS/ excludedRoutes LAN
private func excludedRoutes4(_ o: any LibboxTunOptionsProtocol) -> [NEIPv4Route] {
routePrefixes(o.getInet4RouteExcludeAddress())
.map { NEIPv4Route(destinationAddress: $0.address, subnetMask: $0.mask) }
}
private func excludedRoutes6(_ o: any LibboxTunOptionsProtocol) -> [NEIPv6Route] {
routePrefixes(o.getInet6RouteExcludeAddress())
.map { NEIPv6Route(destinationAddress: $0.address,
networkPrefixLength: NSNumber(value: $0.prefix)) }
}
// utun fd:getpeername sockaddr_ctl, utun id(WireGuard )
private static func tunFd() -> Int32? {
var ctlInfo = ctl_info()
withUnsafeMutableBytes(of: &ctlInfo.ctl_name) { ptr in
_ = "com.apple.net.utun_control".withCString {
strncpy(ptr.baseAddress!.assumingMemoryBound(to: CChar.self), $0, ptr.count - 1)
}
}
// 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)
let ok = withUnsafeMutablePointer(to: &addr) { p in
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { getpeername(fd, $0, &len) }
}
if ok == 0, addr.sc_family == AF_SYSTEM {
if ioctl(fd, ctliocginfo, &ctlInfo) == 0, addr.sc_id == ctlInfo.ctl_id {
return fd
}
}
}
return nil
}
private static func enumerateInterfaces() -> [LibboxNetworkInterface] {
var result: [String: LibboxNetworkInterface] = [:]
var head: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&head) == 0 else { return [] }
defer { freeifaddrs(head) }
var cur = head
while let p = cur {
let name = String(cString: p.pointee.ifa_name)
if result[name] == nil {
let i = LibboxNetworkInterface()
i.name = name
i.index = Int32(if_nametoindex(p.pointee.ifa_name))
result[name] = i
}
cur = p.pointee.ifa_next
}
return Array(result.values)
}
}
// LibboxNetworkInterfaceIterator
final class InterfaceIterator: NSObject, LibboxNetworkInterfaceIteratorProtocol {
private var idx = 0
private let items: [LibboxNetworkInterface]
init(items: [LibboxNetworkInterface]) { self.items = items }
func hasNext() -> Bool { idx < items.count }
func next() -> LibboxNetworkInterface? {
guard idx < items.count else { return nil }
defer { idx += 1 }
return items[idx]
}
}
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)?) {}
}