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:
@@ -23,6 +23,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
private var commandServer: LibboxCommandServer?
|
private var commandServer: LibboxCommandServer?
|
||||||
private var platform: PangolinPlatformInterface?
|
private var platform: PangolinPlatformInterface?
|
||||||
private let memoryMonitor = MemoryMonitor() // ← iOS 新增
|
private let memoryMonitor = MemoryMonitor() // ← iOS 新增
|
||||||
|
private let stats = StatsCollector() // 速率(libbox)+ urltest(clash API)
|
||||||
|
|
||||||
override func startTunnel(options: [String: NSObject]?,
|
override func startTunnel(options: [String: NSObject]?,
|
||||||
completionHandler: @escaping (Error?) -> Void) {
|
completionHandler: @escaping (Error?) -> Void) {
|
||||||
@@ -33,7 +34,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
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
|
guard let base = FileManager.default
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
||||||
@@ -63,6 +71,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
|
try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
|
||||||
self.commandServer = server
|
self.commandServer = server
|
||||||
self.memoryMonitor.start() // ← iOS 新增
|
self.memoryMonitor.start() // ← iOS 新增
|
||||||
|
// 进程内 stats 采集:libbox command.sock 取上/下行;clash API 取 urltest 延迟。
|
||||||
|
self.stats.start(clashPort: clashPort, clashSecret: clashSecret)
|
||||||
log.info("startTunnel: service started")
|
log.info("startTunnel: service started")
|
||||||
completionHandler(nil)
|
completionHandler(nil)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,6 +85,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
override func stopTunnel(with reason: NEProviderStopReason,
|
override func stopTunnel(with reason: NEProviderStopReason,
|
||||||
completionHandler: @escaping () -> Void) {
|
completionHandler: @escaping () -> Void) {
|
||||||
log.info("stopTunnel reason=\(reason.rawValue)")
|
log.info("stopTunnel reason=\(reason.rawValue)")
|
||||||
|
stats.stop()
|
||||||
memoryMonitor.stop() // ← iOS 新增
|
memoryMonitor.stop() // ← iOS 新增
|
||||||
log.info("memory peak: \(self.memoryMonitor.summaryString(), privacy: .public)") // ← iOS 新增
|
log.info("memory peak: \(self.memoryMonitor.summaryString(), privacy: .public)") // ← iOS 新增
|
||||||
try? commandServer?.closeService()
|
try? commandServer?.closeService()
|
||||||
@@ -89,6 +100,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
completionHandler: ((Data?) -> Void)?) {
|
completionHandler: ((Data?) -> Void)?) {
|
||||||
guard let req = try? JSONSerialization.jsonObject(with: messageData) as? [String: Any],
|
guard let req = try? JSONSerialization.jsonObject(with: messageData) as? [String: Any],
|
||||||
let action = req["action"] as? String else { completionHandler?(nil); return }
|
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] = [:]
|
var resp: [String: Any] = [:]
|
||||||
switch action {
|
switch action {
|
||||||
case "getStatus":
|
case "getStatus":
|
||||||
@@ -102,6 +115,27 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
completionHandler?((try? JSONSerialization.data(withJSONObject: resp)) ?? nil)
|
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 {
|
private func resolveConfig(_ options: [String: NSObject]?) throws -> String {
|
||||||
if let inline = options?["config"] as? String, !inline.isEmpty {
|
if let inline = options?["config"] as? String, !inline.isEmpty {
|
||||||
return inline
|
return inline
|
||||||
@@ -285,10 +319,13 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
|||||||
strncpy(ptr.baseAddress!.assumingMemoryBound(to: CChar.self), $0, ptr.count - 1)
|
strncpy(ptr.baseAddress!.assumingMemoryBound(to: CChar.self), $0, ptr.count - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// CTLIOCGINFO = _IOWR('N', 3, struct ctl_info)(Swift 不导出该宏,按定义算)
|
// CTLIOCGINFO = _IOWR('N', 3, struct ctl_info)(Swift 不导出该宏,按定义算)。
|
||||||
let ctliocginfo: UInt = 0x8000_0000 | 0x4000_0000
|
// 拆成子表达式:整条 | 链放一行会让 Swift 类型检查器超时(报 "unable to type-check
|
||||||
| ((UInt(MemoryLayout<ctl_info>.stride) & 0x1fff) << 16)
|
// in reasonable time"),进而连带 ctl_info/sockaddr_ctl 误报 not in scope。
|
||||||
| (UInt(UInt8(ascii: "N")) << 8) | 3
|
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 {
|
for fd in Int32(0)...1024 {
|
||||||
var addr = sockaddr_ctl()
|
var addr = sockaddr_ctl()
|
||||||
var len = socklen_t(MemoryLayout<sockaddr_ctl>.size)
|
var len = socklen_t(MemoryLayout<sockaddr_ctl>.size)
|
||||||
@@ -340,3 +377,201 @@ final class InterfaceIterator: NSObject, LibboxNetworkInterfaceIteratorProtocol
|
|||||||
private func simpleError(_ msg: String) -> NSError {
|
private func simpleError(_ msg: String) -> NSError {
|
||||||
NSError(domain: "pangolin.tunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
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)?) {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
CAFE000000000001000000AB /* VpnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000001000000AA /* VpnManager.swift */; };
|
CAFE000000000001000000AB /* VpnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000001000000AA /* VpnManager.swift */; };
|
||||||
CAFE000000000002000000AB /* StatsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000002000000AA /* StatsClient.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, ); }; };
|
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 */; };
|
CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000006000000AB /* MemoryMonitor.swift */; };
|
||||||
CAFE000000000081000000AB /* Libbox.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = CAFE000000000081000000AA /* Libbox.xcframework */; };
|
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>"; };
|
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>"; };
|
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 /* 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>"; };
|
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; };
|
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>"; };
|
CAFE000000000005000000AA /* Runner.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
CAFE000000000020000000AA /* PacketTunnel */ = {
|
CAFE000000000020000000AA /* PacketTunnel */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
CAFE000000000002000000AA /* PacketTunnelProvider.swift */,
|
CAFE000000000007000000AA /* PacketTunnelProvider.swift */,
|
||||||
CAFE000000000006000000AB /* MemoryMonitor.swift */,
|
CAFE000000000006000000AB /* MemoryMonitor.swift */,
|
||||||
CAFE000000000006000000AA /* PacketTunnel.entitlements */,
|
CAFE000000000006000000AA /* PacketTunnel.entitlements */,
|
||||||
CAFE000000000003000000AA /* Info.plist */,
|
CAFE000000000003000000AA /* Info.plist */,
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */,
|
CAFE000000000007000000AB /* PacketTunnelProvider.swift in Sources */,
|
||||||
CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */,
|
CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -1,108 +1,47 @@
|
|||||||
// StatsClient.swift — 主 App 侧实时统计生产者
|
// StatsClient.swift — 主 App 侧实时统计消费者(iOS)
|
||||||
//
|
//
|
||||||
// iOS 的隧道跑在独立的 PacketTunnel 扩展进程里,扩展已 LibboxSetup 到 App Group 容器
|
// 隧道跑在 PacketTunnel 扩展进程里;扩展用 StatsCollector 采集上/下行(libbox command.sock)
|
||||||
// 并起了 LibboxCommandServer。要拿实时上/下行 + 延迟,主 App 用 LibboxCommandClient
|
// + urltest 延迟(注入的 clash API)并缓存最新 JSON。主 App 经
|
||||||
// 连接「同一个 group 容器路径」下的 CommandServer socket,订阅 status/group 命令——
|
// NETunnelProviderSession.sendProviderMessage(action="stats")每秒拉一次,解析后推到
|
||||||
// 对齐 sing-box-for-apple(SFI) 与 Android 的 StatsHandler 模型。
|
// pangolin/vpn/stats EventChannel——与 macOS 同架构(扩展产出、app 经 NE 跨进程拉取)。
|
||||||
//
|
//
|
||||||
// CommandServer 会按 statusInterval 自动向已连接的 client 广播 box 统计;client 的
|
// 之前的做法(主 App 用 LibboxCommandClient 直连共享容器 socket)有两个坑:① 单 client 同时
|
||||||
// writeStatus/writeGroups 回调拿到数据后映射成 pangolin/vpn/stats 契约字典
|
// 订 Status+Group → Group 永不回调(libbox 一连接一命令);② 配置无 clash_api → 内核不暴露
|
||||||
// (见 lib/bridge/vpn_bridge.dart),经 VpnStatsStreamHandler 推到 Dart 侧。
|
// urltest。改走扩展 StatsCollector + sendProviderMessage 后两坑同解,与 macOS 一致。
|
||||||
//
|
//
|
||||||
// 生命周期:VPN 进入 connected → start();断开/失效 → stop()。由 VpnManager 驱动。
|
// 生命周期:VPN 进入 connected → start();断开/失效 → stop()。由 VpnManager 驱动。
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Libbox
|
|
||||||
|
|
||||||
/// 把 LibboxCommandClient 的实时回调映射成 stats 契约字典并推到 EventChannel。
|
|
||||||
final class StatsClient: NSObject {
|
final class StatsClient: NSObject {
|
||||||
|
|
||||||
static let shared = StatsClient()
|
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 let queue = DispatchQueue(label: "pangolin.stats.client")
|
||||||
private var client: LibboxCommandClient?
|
private var timer: DispatchSourceTimer?
|
||||||
private var started = false
|
|
||||||
// libbox 全局 setup 每进程一次即可。
|
|
||||||
private static var didSetup = false
|
|
||||||
|
|
||||||
/// 缓存最近一次 writeGroups 解析出的 urltest 延迟(tag → delayMs),
|
|
||||||
/// 在 writeStatus 时一并打包(对齐 Android:status 与 group 是两个命令流)。
|
|
||||||
private var latestUrltest: [(tag: String, delayMs: Int)] = []
|
|
||||||
|
|
||||||
/// writeGroups 捕获的出口组 tag(如 urltest 的 "auto"),用于主动触发延迟探测。
|
|
||||||
private var groupTags: [String] = []
|
|
||||||
/// 周期主动 urltest:sing-box urltest 默认惰性探测(首个 interval 前 history 为空),
|
|
||||||
/// 这里每 12s 主动触发一次,让延迟稳定、及时刷新。
|
|
||||||
private var urlTestTimer: DispatchSourceTimer?
|
|
||||||
|
|
||||||
// ── 生命周期 ────────────────────────────────────────────────
|
// ── 生命周期 ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 隧道已连接后调用:建立并连接 CommandClient。幂等。
|
/// 隧道已连接后调用:每秒向扩展拉一次统计。幂等。
|
||||||
func start() {
|
func start() {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
guard let self, !self.started else { return }
|
guard let self else { return }
|
||||||
guard self.ensureSetup() else { return }
|
self.timer?.cancel()
|
||||||
|
let t = DispatchSource.makeTimerSource(queue: self.queue)
|
||||||
let options = LibboxCommandClientOptions()
|
t.schedule(deadline: .now() + 0.2, repeating: 1.0)
|
||||||
options.statusInterval = 1_000_000_000 // 1s(纳秒)
|
t.setEventHandler { [weak self] in self?.poll() }
|
||||||
options.addCommand(LibboxCommandStatus) // 实时上/下行 + 累计
|
t.resume()
|
||||||
options.addCommand(LibboxCommandGroup) // 出口组 → urltest 延迟
|
self.timer = t
|
||||||
|
NSLog("StatsClient: polling via sendProviderMessage started")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 隧道断开后调用:断开并释放 CommandClient。幂等。
|
/// 隧道断开后调用:停定时器并推零帧,避免 UI 残留上一次连接的速率。幂等。
|
||||||
/// 周期主动触发 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.urlTestTimer?.cancel()
|
self.timer?.cancel()
|
||||||
self.urlTestTimer = nil
|
self.timer = nil
|
||||||
if let c = self.client {
|
|
||||||
try? c.disconnect()
|
|
||||||
}
|
|
||||||
self.client = nil
|
|
||||||
self.started = false
|
|
||||||
self.latestUrltest = []
|
|
||||||
self.groupTags = []
|
|
||||||
// 断开后清零,避免 UI 残留上一次连接的速率。
|
|
||||||
VpnStatsStreamHandler.shared.push([
|
VpnStatsStreamHandler.shared.push([
|
||||||
"uploadBytes": 0, "downloadBytes": 0,
|
"uploadBytes": 0, "downloadBytes": 0,
|
||||||
"uploadSpeed": 0.0, "downloadSpeed": 0.0,
|
"uploadSpeed": 0.0, "downloadSpeed": 0.0,
|
||||||
@@ -111,68 +50,12 @@ final class StatsClient: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── libbox 全局 setup(与扩展同一 group 容器路径)─────────────
|
private func poll() {
|
||||||
|
let req = (try? JSONSerialization.data(withJSONObject: ["action": "stats"])) ?? Data()
|
||||||
private func ensureSetup() -> Bool {
|
VpnManager.shared.sendProviderMessage(req) { resp, _ in
|
||||||
if Self.didSetup { return true }
|
guard let resp, !resp.isEmpty,
|
||||||
guard let base = FileManager.default
|
let obj = (try? JSONSerialization.jsonObject(with: resp)) as? [String: Any] else { return }
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
VpnStatsStreamHandler.shared.push(obj)
|
||||||
NSLog("StatsClient: no app group container")
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user