feat(client/macos): P1 方案B 骨架 — System Extension + NETunnelProviderManager 接线
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

把 PoC 的 sudo sing-box 外部二进制换成自包含、免 root 的 NEPacketTunnelProvider
(System Extension)+ 嵌入 libbox 的生产架构铺好骨架(不破坏现有 PoC 构建)。

- PacketTunnel/:扩展 target 源 — PacketTunnelProvider(LibboxSetup→NewService→start,
  openTun 建 NEPacketTunnelNetworkSettings)、Info.plist(NEProviderClasses)、
  entitlements(packet-tunnel-provider-systemextension + App Group)
- Runner/VpnChannel.swift:主 app 经 NETunnelProviderManager 启停 + 状态/速率回传,
  对齐 Dart 侧 VpnNativeBridge 的 pangolin/vpn channel 契约
- vpn_bridge_provider.dart:kUseNativeVpnMacOS 开关(默认 false,联调通过后置 true)
- docs/p1-macos-system-extension.md:文件清单 + Xcode/签名步骤 + 待办
  (Team BYL4KQHMTN;Network Extensions 已确认自助开通、无需 Apple 审批)

非破坏:新源文件未入 build target、注册行/app-group entitlements 均注释、gate 默认 false。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 11:07:36 +08:00
parent 64a1a64c3f
commit b25c8bbc2c
10 changed files with 480 additions and 6 deletions
@@ -15,5 +15,11 @@
<array>
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
</array>
<!-- P1 方案B:接 System Extension 时在此加(注册 App Group 后,见 p1-macos-system-extension.md §3):
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.pangolin.pangolin</string>
</array>
-->
</dict>
</plist>
@@ -14,6 +14,11 @@ class MainFlutterWindow: NSWindow {
RegisterGeneratedPlugins(registry: flutterViewController)
// P1 B:VPN VpnChannel.swift Runner target(Compile Sources)
// ,( docs/p1-macos-system-extension.md §3),
// Runner
// VpnChannel.register(with: flutterViewController.registrar(forPlugin: "VpnChannel"))
super.awakeFromNib()
}
}
+6
View File
@@ -11,5 +11,11 @@
<array>
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
</array>
<!-- P1 方案B:接 System Extension 时在此加(注册 App Group 后,见 p1-macos-system-extension.md §3):
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.pangolin.pangolin</string>
</array>
-->
</dict>
</plist>
+164
View File
@@ -0,0 +1,164 @@
// 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
private let log = Logger(subsystem: "com.pangolin.pangolin", category: "vpn")
final class VpnChannel: NSObject {
private static let tunnelBundleId = "com.pangolin.pangolin.tunnel"
private var statusSink: FlutterEventSink?
private var statsSink: FlutterEventSink?
private var statusObserver: NSObjectProtocol?
private var statsTimer: Timer?
private var manager: NETunnelProviderManager?
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 {
do {
try await activateSystemExtensionIfNeeded()
let mgr = try await loadOrCreateManager()
self.manager = mgr
try mgr.connection.startVPNTunnel(options: [
"configContent": configJson as NSString,
])
result(nil)
} catch {
log.error("start failed: \(error.localizedDescription)")
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()
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
try await mgr.saveToPreferences()
try await mgr.loadFromPreferences() // , connection
return mgr
}
// sysex
private func activateSystemExtensionIfNeeded() async throws {
// TODO(P1): OSSystemExtensionRequest.activationRequest(
// forExtensionWithIdentifier: tunnelBundleId, queue: .main) + delegate
// 4
}
// /
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 }
self?.statusSink?(Self.statusString(conn.status))
}
}
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 }
}