Files
pangolin/client/macos/Runner/VpnChannel.swift
T
wangjia 447f3f494e feat(client/macos): P1 原生隧道——PacketTunnel 系统扩展可加载 + libbox 运行
经长链路排查(同机对照可工作的 Tailscale),修复 macOS 系统扩展 realize
失败(OSSystemExtensionErrorDomain code=4)与 libbox 运行时崩溃,使内嵌
sing-box 的系统扩展能在 macOS 15 上激活并启动隧道。

系统扩展 realize(三个叠加根因):
- 扩展自包含:PacketTunnel 加 OTHER_LDFLAGS="" 切断对项目级 CocoaPods 链接
  标志的继承(原会把 flutter_secure_storage 链进扩展);Libbox.xcframework
  改纯 Link(静态),从 Embed Frameworks 移除冗余内嵌
- bundle 名 = 标识符:PRODUCT_NAME 设为 com.pangolin.pangolin.PacketTunnel
- 扩展 Info.plist 补 NSSystemExtensionUsageDescription(网络扩展类别强制要求)
- App Group 改 macOS 原生格式 BYL4KQHMTN.com.pangolin.pangolin;NEMachServiceName
  以其为前缀;扩展补 network.client/server;get-task-allow=false + 签名加 --timestamp
- CFBundleVersion 随构建递增(否则 sysextd 视为同版本不更新)

libbox 运行时:
- startOrReloadService(options:) 传 nil 致空指针 SIGSEGV → 传 LibboxOverrideOptions()
- 默认接口监控阻塞到首个 path 更新再返回,修 "no available network interface"

配套:
- scripts/local_test.sh:build/sign/notarize/copy/run 一条龙(Developer ID + 公证)
- client/macos/sign_libbox.sh:构建期以 Developer ID 重签内嵌 Libbox
- VpnChannel:401 自动刷新 token、详尽 os_log;auth/api 统一走 kApiBaseUrl
- docs/macos-sysext-realize-troubleshooting.html:完整踩坑复盘

WIP / 临时(后续清理):
- 隧道运行时仍在排查:剥离远程 rule-set 后 sing-box 启动卡点未定位
- 含临时诊断代码:main.swift stderr 重定向、box.log 输出、rule-set 剥离、debug 日志
- api_config 仍指向联调节点,发版前还原

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
2026-06-21 21:18:33 +08:00

235 lines
12 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// VpnChannel.swift app VPN (B / P1 )
//
// Flutter pangolin/vpn(MethodChannel)+ pangolin/vpn/statuspangolin/vpn/stats
// (EventChannel) NETunnelProviderManager:/ PacketTunnel System Extension
// client/lib/bridge/vpn_bridge.dart VpnNativeBridge
//
// : MainFlutterWindow.awakeFromNib `VpnChannel.register(with: flutterViewController)`
//
// ** System Extension**(OSSystemExtensionRequest,
// activateSystemExtensionIfNeeded + 4),
// ; NETunnelProviderManager provider
import FlutterMacOS
import NetworkExtension
import SystemExtensions
import os.log
// os_log + %{public} Console.app / `log show` (NSLog %@
// redact <private>,)os_log(C API) macOS 10.12 ,
// Runner 10.15 :`log show --predicate 'subsystem == "com.pangolin.pangolin"'`
private let vpnLogObj = OSLog(subsystem: "com.pangolin.pangolin", category: "vpn")
private func vpnLog(_ message: String) {
os_log("%{public}@", log: vpnLogObj, type: .default, message)
NSLog("[pangolin/vpn] %@", message) // stderr,flutter run
}
final class VpnChannel: NSObject {
private static let tunnelBundleId = "com.pangolin.pangolin.PacketTunnel"
private var statusSink: FlutterEventSink?
private var statsSink: FlutterEventSink?
private var statusObserver: NSObjectProtocol?
private var statsTimer: Timer?
private var manager: NETunnelProviderManager?
private var sysextDelegate: SysExtActivationDelegate?
static func register(with registrar: FlutterPluginRegistrar) {
let instance = VpnChannel()
let method = FlutterMethodChannel(name: "pangolin/vpn",
binaryMessenger: registrar.messenger)
method.setMethodCallHandler(instance.handle)
FlutterEventChannel(name: "pangolin/vpn/status", binaryMessenger: registrar.messenger)
.setStreamHandler(StatusStreamHandler(owner: instance))
FlutterEventChannel(name: "pangolin/vpn/stats", binaryMessenger: registrar.messenger)
.setStreamHandler(StatsStreamHandler(owner: instance))
instance.observeStatus()
}
// MethodChannel
private func handle(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
switch call.method {
case "start":
guard let configJson = call.arguments as? String else {
result(FlutterError(code: "bad_args", message: "expected config json", details: nil))
return
}
Task { await self.start(configJson, result) }
case "stop":
Task { await self.stop(result) }
case "getStatus":
result(Self.statusString(manager?.connection.status ?? .invalid))
case "selectOutbound":
// TODO: sendProviderMessage / libbox CommandClient outbound
result(nil)
case "getActiveOutbound":
result("auto")
case "setKillSwitch":
// includeAllNetworks / on-demand kill switch()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
private func start(_ configJson: String, _ result: @escaping FlutterResult) async {
vpnLog("start() 收到调用, config 长度=\(configJson.count) bytes")
do {
vpnLog("step① 激活 System Extension …")
try await activateSystemExtensionIfNeeded()
vpnLog("step① System Extension 激活完成 ✓")
vpnLog("step② 装配 NETunnelProviderManager …")
let mgr = try await loadOrCreateManager()
self.manager = mgr
vpnLog("step② manager 就绪, 当前隧道状态=\(Self.statusString(mgr.connection.status))")
vpnLog("step③ startVPNTunnel(options: configContent) …")
try mgr.connection.startVPNTunnel(options: [
"configContent": configJson as NSString,
])
vpnLog("step③ startVPNTunnel 调用已返回(实际起停由 NEVPNStatus 流驱动)✓")
result(nil)
} catch {
let ns = error as NSError
vpnLog("start FAILED ✗ domain=\(ns.domain) code=\(ns.code) desc=\(ns.localizedDescription) userInfo=\(ns.userInfo)")
result(FlutterError(code: "start_failed", message: error.localizedDescription, details: nil))
}
}
private func stop(_ result: @escaping FlutterResult) async {
manager?.connection.stopVPNTunnel()
result(nil)
}
// NETunnelProviderManager
private func loadOrCreateManager() async throws -> NETunnelProviderManager {
let all = try await NETunnelProviderManager.loadAllFromPreferences()
vpnLog(" loadAllFromPreferences: 已有 \(all.count) 个 VPN 配置")
let mgr = all.first ?? NETunnelProviderManager()
let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol()
proto.providerBundleIdentifier = Self.tunnelBundleId
proto.serverAddress = "Pangolin" //
mgr.protocolConfiguration = proto
mgr.localizedDescription = "Pangolin"
mgr.isEnabled = true
vpnLog(" saveToPreferences(providerBundleId=\(Self.tunnelBundleId)) …")
try await mgr.saveToPreferences()
try await mgr.loadFromPreferences() // , connection
vpnLog(" manager 保存+重载完成 ✓")
return mgr
}
// / PacketTunnel System Extension
// ; didFinishWithResult
private func activateSystemExtensionIfNeeded() async throws {
vpnLog(" 提交 OSSystemExtensionRequest.activationRequest(id=\(Self.tunnelBundleId)) …")
vpnLog(" 主 bundle=\(Bundle.main.bundlePath)")
let sysextDir = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/SystemExtensions").path
vpnLog(" SystemExtensions 目录=\(sysextDir) 内容=\((try? FileManager.default.contentsOfDirectory(atPath: sysextDir)) ?? ["<读取失败>"])")
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let req = OSSystemExtensionRequest.activationRequest(
forExtensionWithIdentifier: Self.tunnelBundleId, queue: .main)
let delegate = SysExtActivationDelegate(continuation: cont)
self.sysextDelegate = delegate //
req.delegate = delegate
OSSystemExtensionManager.shared.submitRequest(req)
vpnLog(" submitRequest 已提交, 等待 sysextd 回调(didFinish / didFail / needsApproval)…")
}
}
// /
private func observeStatus() {
statusObserver = NotificationCenter.default.addObserver(
forName: .NEVPNStatusDidChange, object: nil, queue: .main
) { [weak self] note in
guard let conn = note.object as? NEVPNConnection else { return }
let s = Self.statusString(conn.status)
vpnLog("NEVPNStatus 变化 → \(s) (raw=\(conn.status.rawValue))")
self?.statusSink?(s)
}
}
fileprivate func onStatusListen(_ sink: @escaping FlutterEventSink) {
statusSink = sink
sink(Self.statusString(manager?.connection.status ?? .invalid))
}
fileprivate func onStatusCancel() { statusSink = nil }
fileprivate func onStatsListen(_ sink: @escaping FlutterEventSink) {
statsSink = sink
// TODO(P1): libbox CommandClient ; 0
statsTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.statsSink?(["up": 0, "down": 0, "uplinkTotal": 0, "downlinkTotal": 0])
}
}
fileprivate func onStatsCancel() { statsTimer?.invalidate(); statsTimer = nil; statsSink = nil }
private static func statusString(_ s: NEVPNStatus) -> String {
switch s {
case .connected: return "on"
case .connecting, .reasserting: return "connecting"
case .disconnecting: return "disconnecting"
case .disconnected, .invalid: return "off"
@unknown default: return "error"
}
}
}
// EventChannel
private final class StatusStreamHandler: NSObject, FlutterStreamHandler {
weak var owner: VpnChannel?
init(owner: VpnChannel) { self.owner = owner }
func onListen(withArguments _: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
owner?.onStatusListen(events); return nil
}
func onCancel(withArguments _: Any?) -> FlutterError? { owner?.onStatusCancel(); return nil }
}
private final class StatsStreamHandler: NSObject, FlutterStreamHandler {
weak var owner: VpnChannel?
init(owner: VpnChannel) { self.owner = owner }
func onListen(withArguments _: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
owner?.onStatsListen(events); return nil
}
func onCancel(withArguments _: Any?) -> FlutterError? { owner?.onStatsCancel(); return nil }
}
// System Extension ,
// didFinishWithResult( await ,
// )
private final class SysExtActivationDelegate: NSObject, OSSystemExtensionRequestDelegate {
private let continuation: CheckedContinuation<Void, Error>
private var resumed = false
init(continuation: CheckedContinuation<Void, Error>) { self.continuation = continuation }
func request(_ request: OSSystemExtensionRequest,
didFinishWithResult result: OSSystemExtensionRequest.Result) {
vpnLog("sysext didFinishWithResult ✓ result=\(result.rawValue) (0=completed, 1=willCompleteAfterReboot)")
guard !resumed else { return }
resumed = true
continuation.resume()
}
func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
let ns = error as NSError
// OSSystemExtensionErrorDomain code :1 unknown,2 missingEntitlement,
// 3 unsupportedParentBundleLocation,4 extensionNotFound,8 codeSignatureInvalid,
// 9 validationFailed,10 forbiddenBySystemPolicy,13 authorizationRequired
vpnLog("sysext didFailWithError ✗ domain=\(ns.domain) code=\(ns.code) desc=\(ns.localizedDescription)")
guard !resumed else { return }
resumed = true
continuation.resume(throwing: error)
}
func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
vpnLog("sysext requestNeedsUserApproval —— 需在 系统设置 → 隐私与安全性 点「允许」(等待中…)")
}
func request(_ request: OSSystemExtensionRequest,
actionForReplacingExtension existing: OSSystemExtensionProperties,
withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
vpnLog("sysext 替换扩展: 已装 v\(existing.bundleVersion)/\(existing.bundleShortVersion) → 新 v\(ext.bundleVersion)/\(ext.bundleShortVersion), 选择 replace")
return .replace
}
}