Files
pangolin/client/macos/Runner/VpnChannel.swift
T
wangjia b25c8bbc2c
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
feat(client/macos): P1 方案B 骨架 — System Extension + NETunnelProviderManager 接线
把 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>
2026-06-19 11:07:36 +08:00

165 lines
7.3 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
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 }
}