burrow/Apple/UI/NetworkExtensionTunnel.swift
JettChenT d1638726ca Add proxy subscription runtime support
Add daemon RPCs, Apple and GTK import flows, packet proxy runtime support, diagnostics, and BEPs for proxy subscription handling.

Redact subscription URL secrets from fetch errors before they reach logs or UI surfaces.
2026-06-01 15:23:17 +08:00

192 lines
5.7 KiB
Swift

import BurrowCore
import NetworkExtension
@Observable
public final class NetworkExtensionTunnel: Tunnel {
@MainActor public private(set) var status: TunnelStatus = .unknown
@MainActor private var error: NEVPNError?
private let logger = Logger.logger(for: Tunnel.self)
private let bundleIdentifier: String
private let configurationChanged: Task<Void, Error>
private let statusChanged: Task<Void, Error>
// Each manager corresponds to one entry in the Settings app.
// Our goal is to maintain a single manager, so we create one if none exist and delete any extra.
@MainActor private var managers: [NEVPNManager]? {
didSet { Task { await updateStatus() } }
}
@MainActor private var currentStatus: TunnelStatus {
guard let managers = managers else {
guard let error = error else {
return .unknown
}
switch error.code {
case .configurationReadWriteFailed:
return .configurationReadWriteFailed
default:
return .unknown
}
}
guard let manager = managers.first else {
return .permissionRequired
}
guard manager.isEnabled else {
return .disabled
}
return manager.connection.tunnelStatus
}
public init(bundleIdentifier: String) {
self.bundleIdentifier = bundleIdentifier
let center = NotificationCenter.default
let tunnel: OSAllocatedUnfairLock<NetworkExtensionTunnel?> = .init(initialState: .none)
configurationChanged = Task {
for try await _ in center.notifications(named: .NEVPNConfigurationChange) {
try Task.checkCancellation()
await tunnel.withLock { $0 }?.update()
}
}
statusChanged = Task {
for try await _ in center.notifications(named: .NEVPNStatusDidChange) {
try Task.checkCancellation()
await tunnel.withLock { $0 }?.updateStatus()
}
}
tunnel.withLock { $0 = self }
Task { await update() }
}
private func update() async {
do {
let result = try await NETunnelProviderManager.managers
await MainActor.run {
managers = result
status = currentStatus
}
await self.updateStatus()
} catch let vpnError as NEVPNError {
await MainActor.run {
error = vpnError
}
} catch {
logger.error("Failed to update VPN configurations: \(error)")
}
}
private func updateStatus() async {
await MainActor.run {
status = currentStatus
}
}
func configure() async throws {
let managers = try await NETunnelProviderManager.managers
if managers.count > 1 {
try await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in
for manager in managers.suffix(from: 1) {
group.addTask { try await manager.remove() }
}
try await group.waitForAll()
}
}
let manager = managers.first ?? NETunnelProviderManager()
var needsSave = managers.isEmpty
if manager.localizedDescription != "Burrow" {
manager.localizedDescription = "Burrow"
needsSave = true
}
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = bundleIdentifier
proto.serverAddress = "burrow.rs"
proto.proxySettings = nil
if !manager.protocolConfiguration.isBurrowEquivalent(to: proto) {
manager.protocolConfiguration = proto
needsSave = true
}
if needsSave {
try await manager.save()
}
}
public func start() {
Task {
do {
try await configure()
guard let manager = try await NETunnelProviderManager.managers.first else { return }
if !manager.isEnabled {
manager.isEnabled = true
try await manager.save()
}
try manager.connection.startVPNTunnel()
} catch {
logger.error("Failed to start: \(error)")
}
}
}
public func stop() {
Task {
guard let manager = try await NETunnelProviderManager.managers.first else { return }
manager.connection.stopVPNTunnel()
}
}
public func enable() {
Task {
do {
try await configure()
} catch {
logger.error("Failed to enable: \(error)")
}
}
}
deinit {
configurationChanged.cancel()
statusChanged.cancel()
}
}
private extension Optional where Wrapped == NEVPNProtocol {
func isBurrowEquivalent(to expected: NETunnelProviderProtocol) -> Bool {
guard let current = self as? NETunnelProviderProtocol else {
return false
}
return current.providerBundleIdentifier == expected.providerBundleIdentifier
&& current.serverAddress == expected.serverAddress
&& current.proxySettings == nil
}
}
extension NEVPNConnection {
fileprivate var tunnelStatus: TunnelStatus {
switch status {
case .connected:
.connected(connectedDate!)
case .connecting:
.connecting
case .disconnecting:
.disconnecting
case .disconnected:
.disconnected
case .reasserting:
.reasserting
case .invalid:
.invalid
@unknown default:
.unknown
}
}
}