a32d87c9c0
实测 includeAllNetworks=true 把所有流量(含 sing-box 连服务器握手、app 调控制面 请求)在隧道建起前就塞进隧道 → 握手出不去 → 连接失败 + on-demand 死循环 + 整机 断网(连「我的」页账户信息都拉不到、显示 —)。 改为不会误伤握手/控制面的组合: - configureKillSwitch:includeAllNetworks 恒 false;保留 enforceRoutes(连接期防漏) + excludeLocalNetworks + NEOnDemandRule 常开(兜重连) - 撤回 start() 的 killSwitch option 及扩展侧 includeAllNetworks 透传(回 false) - 诚实标注:当前 L1→L2 之间,未到 includeAllNetworks-L3;要拿「扛进程被杀」须先 在扩展内放行服务器/控制面连接,留待真机验证 docs/killswitch-design.html: §6.5 加踩坑修正、状态表 macOS 改「L1→L2 之间」 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
571 lines
27 KiB
Swift
571 lines
27 KiB
Swift
// PacketTunnelProvider.swift — Pangolin macOS 隧道扩展(方案B / P1)
|
||
//
|
||
// NEPacketTunnelProvider 子进程,把嵌入的 sing-box(libbox)拉起来建 TUN,免 root、
|
||
// 自包含。数据流见 docs/p1-macos-system-extension.md。
|
||
//
|
||
// gomobile 约定:每个 Go 接口生成同名 protocol + class;Swift 里实现用 `XxxProtocol`,
|
||
// 参数/返回用同名 class。NE 生命周期 override 用 completion-handler(规避 Swift 6.2
|
||
// async→ObjC 桥接 thunk 的编译器崩溃)。
|
||
|
||
import Darwin
|
||
import Foundation
|
||
import Libbox
|
||
import Network
|
||
import NetworkExtension
|
||
import os
|
||
|
||
private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider")
|
||
// macOS 原生 App Group 格式 <TeamID>.<name>(非 iOS 的 group. 前缀)。
|
||
// sysextd 校验请求方 app 时要求此格式;用 group. 式会在 realize 暂存前被拒。
|
||
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) {
|
||
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 历史 → 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 的目录/属主
|
||
// 与无 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)
|
||
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
|
||
|
||
// 进程内 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()
|
||
try? commandServer?.closeService()
|
||
commandServer?.close()
|
||
commandServer = nil
|
||
platform?.stopMonitor()
|
||
platform = nil
|
||
completionHandler()
|
||
}
|
||
|
||
// 主 app 经 NETunnelProviderSession.sendProviderMessage 拉取实时统计。约定:
|
||
// 发送 UTF-8 "stats" → 回最新统计 JSON(契约同 pangolin/vpn/stats);其他消息回 nil。
|
||
// 这是 macOS 上 app↔扩展跨进程(且跨 root/用户边界)的正路,免去共享文件 socket 的
|
||
// 容器/属主死局。
|
||
override func handleAppMessage(_ messageData: Data,
|
||
completionHandler: ((Data?) -> Void)?) {
|
||
let req = String(data: messageData, encoding: .utf8) ?? ""
|
||
if req == "stats" {
|
||
completionHandler?(stats.latestJSON())
|
||
} else {
|
||
completionHandler?(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?["configContent"] as? String, !inline.isEmpty {
|
||
return inline
|
||
}
|
||
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)
|
||
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)
|
||
settings.ipv6Settings = ipv6
|
||
}
|
||
if let dns = try? options.getDNSServerAddress(), !dns.value.isEmpty {
|
||
settings.dnsSettings = NEDNSSettings(servers: [dns.value])
|
||
}
|
||
|
||
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)) }
|
||
}
|
||
|
||
// 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 不导出该宏,按定义算)
|
||
let ctliocginfo: UInt = 0x8000_0000 | 0x4000_0000
|
||
| ((UInt(MemoryLayout<ctl_info>.stride) & 0x1fff) << 16)
|
||
| (UInt(UInt8(ascii: "N")) << 8) | 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:扩展进程内统计采集 ──────────────────────────────────────
|
||
// 上/下行速率:连本进程自己的 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")
|
||
private var statusClient: LibboxCommandClient?
|
||
private var started = false
|
||
private var clashTimer: DispatchSourceTimer?
|
||
|
||
// clash API(扩展内注入,127.0.0.1 本地监听):取出站组 urltest 真实延迟。
|
||
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 延迟(与 Windows getGroupDelay 同法)─────────
|
||
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
|
||
}
|
||
|
||
// 与 Windows(kernel_process.dart)完全同策略:
|
||
// · 显示源 = /proxies 里各 URLTest 成员的 history.last.delay(每拍读,缓存住,稳定)。
|
||
// · /group/<name>/delay 只为「让内核重测并写进 history」,其结果也顺手缓存(双保险);
|
||
// 单次探测失败不影响显示——下一拍仍读到 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 延迟(与 Windows
|
||
/// extractUrltestResults 字段完全一致:group.all → member.history.last.delay)。
|
||
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)?) {}
|
||
}
|