Files
pangolin/client/ios/PacketTunnel/PacketTunnelProvider.swift
wangjia ae5d497f0b 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>
2026-06-30 19:12:28 +08:00

578 lines
27 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)
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 {
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)) }
}
// 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)?) {}
}