diff --git a/Apple/App/BurrowApp.swift b/Apple/App/BurrowApp.swift index b145dea..da0bf52 100644 --- a/Apple/App/BurrowApp.swift +++ b/Apple/App/BurrowApp.swift @@ -1,10 +1,18 @@ +import NetworkExtension import SwiftUI @main +@MainActor struct BurrowApp: App { + + static let tunnel = Tunnel { manager, proto in + proto.serverAddress = "hackclub.com" + manager.localizedDescription = "Burrow" + } + var body: some Scene { WindowGroup { - ContentView() + TunnelView(tunnel: Self.tunnel) } } } diff --git a/Apple/App/ContentView.swift b/Apple/App/ContentView.swift deleted file mode 100644 index f54deab..0000000 --- a/Apple/App/ContentView.swift +++ /dev/null @@ -1,19 +0,0 @@ -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/Apple/App/NetworkExtension+Async.swift b/Apple/App/NetworkExtension+Async.swift new file mode 100644 index 0000000..ba478f3 --- /dev/null +++ b/Apple/App/NetworkExtension+Async.swift @@ -0,0 +1,41 @@ +import NetworkExtension + +extension NEVPNManager { + func remove() async throws { + let _: Void = try await withUnsafeThrowingContinuation { continuation in + removeFromPreferences(completionHandler: completion(continuation)) + } + } + + func save() async throws { + let _: Void = try await withUnsafeThrowingContinuation { continuation in + saveToPreferences(completionHandler: completion(continuation)) + } + } +} + +extension NETunnelProviderManager { + class var managers: [NETunnelProviderManager] { + get async throws { + try await withUnsafeThrowingContinuation { continuation in + loadAllFromPreferences { managers, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: managers ?? []) + } + } + } + } + } +} + +private func completion(_ continuation: UnsafeContinuation) -> (Error?) -> Void { + return { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } +} diff --git a/Apple/App/Status.swift b/Apple/App/Status.swift new file mode 100644 index 0000000..d2ed65a --- /dev/null +++ b/Apple/App/Status.swift @@ -0,0 +1,43 @@ +import Foundation +import NetworkExtension + +extension Tunnel { + enum Status: CustomStringConvertible, Equatable, Hashable { + case unknown + case permissionRequired + case disabled + case connecting + case connected(Date) + case disconnecting + case disconnected + case reasserting + case invalid + case configurationReadWriteFailed + + var description: String { + switch self { + case .unknown: + return "Unknown" + case .permissionRequired: + return "Permission Required" + case .disconnected: + return "Disconnected" + case .disabled: + return "Disabled" + case .connecting: + return "Connecting" + case .connected(_): + return "Connected" + case .disconnecting: + return "Disconnecting" + case .reasserting: + return "Reasserting" + case .invalid: + return "Invalid" + case .configurationReadWriteFailed: + return "System Error" + } + } + } +} + diff --git a/Apple/App/Tunnel.swift b/Apple/App/Tunnel.swift new file mode 100644 index 0000000..64808d5 --- /dev/null +++ b/Apple/App/Tunnel.swift @@ -0,0 +1,135 @@ +import NetworkExtension +import SwiftUI +import Combine + +@MainActor +class Tunnel: ObservableObject { + @Published private(set) var status: Status = .unknown + @Published private var error: NEVPNError? + + private let bundleIdentifier: String + private let configure: (NETunnelProviderManager, NETunnelProviderProtocol) -> Void + private var tasks: [Task] = [] + + private var managers: [NEVPNManager]? { + didSet { status = currentStatus } + } + + private var currentStatus: Status { + 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 + } + + convenience init(configure: @escaping (NETunnelProviderManager, NETunnelProviderProtocol) -> Void) { + self.init("com.hackclub.burrow.network", configure: configure) + } + + init(_ bundleIdentifier: String, configure: @escaping (NETunnelProviderManager, NETunnelProviderProtocol) -> Void) { + self.bundleIdentifier = bundleIdentifier + self.configure = configure + + let statusTask = Task { + for try await _ in NotificationCenter.default.notifications(named: .NEVPNStatusDidChange) { + status = currentStatus + } + } + let configurationTask = Task { + for try await _ in NotificationCenter.default.notifications(named: .NEVPNConfigurationChange) { + await update() + } + } + tasks = [statusTask, configurationTask] + } + + deinit { + tasks.forEach { $0.cancel() } + } + + func update() async { + do { + managers = try await NETunnelProviderManager.managers + } catch let error as NEVPNError { + self.error = error + } catch { + print(error) + } + } + + func configure() async throws { + if managers == nil { + await update() + } + + guard let managers = managers else { return } + + 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() + } + } + + if managers.isEmpty { + let manager = NETunnelProviderManager() + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = bundleIdentifier + manager.protocolConfiguration = proto + + configure(manager, proto) + try await manager.save() + } + } + + func start() throws { + guard let manager = managers?.first else { return } + try manager.connection.startVPNTunnel() + } + + func stop() { + guard let manager = managers?.first else { return } + manager.connection.stopVPNTunnel() + } +} + +private extension NEVPNConnection { + var tunnelStatus: Tunnel.Status { + switch status { + case .connected: + return .connected(connectedDate!) + case .connecting: + return .connecting + case .disconnecting: + return .disconnecting + case .disconnected: + return .disconnected + case .reasserting: + return .reasserting + case .invalid: + return .invalid + @unknown default: + return .unknown + } + } +} diff --git a/Apple/App/TunnelView.swift b/Apple/App/TunnelView.swift new file mode 100644 index 0000000..58153d4 --- /dev/null +++ b/Apple/App/TunnelView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct TunnelView: View { + + @ObservedObject + var tunnel: Tunnel + + var body: some View { + VStack { + Text(verbatim: tunnel.status.description) + switch tunnel.status { + case .connected(_): + Button("Disconnect", action: stop) + case .permissionRequired: + Button("Allow", action: configure) + case .disconnected: + Button("Start", action: start) + default: + EmptyView() + } + } + .task { await tunnel.update() } + .padding() + } + + private func start() { + try? tunnel.start() + } + + private func stop() { + tunnel.stop() + } + + private func configure() { + Task { try await tunnel.configure() } + } +} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index e69eb6c..f65dc39 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -10,8 +10,11 @@ D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */; }; - D05B9F7829E39EEC008CB1F9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7729E39EEC008CB1F9 /* ContentView.swift */; }; + D05B9F7829E39EEC008CB1F9 /* TunnelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7729E39EEC008CB1F9 /* TunnelView.swift */; }; D05B9F7A29E39EED008CB1F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D05B9F7929E39EED008CB1F9 /* Assets.xcassets */; }; + D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */; }; + D0BCC5FF2A086E1C00AD070D /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BCC5FE2A086E1C00AD070D /* Status.swift */; }; + D0BCC6082A0981FE00AD070D /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B98FC629FDC5B5004E7149 /* Tunnel.swift */; }; D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; D0BCC60A2A09A0B800AD070D /* build-rust.sh in Resources */ = {isa = PBXBuildFile; fileRef = D0B98FBF29FD8072004E7149 /* build-rust.sh */; }; /* End PBXBuildFile section */ @@ -57,11 +60,13 @@ D020F66929E4AA74002790F6 /* App-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "App-macOS.entitlements"; sourceTree = ""; }; D05B9F7229E39EEC008CB1F9 /* Burrow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Burrow.app; sourceTree = BUILT_PRODUCTS_DIR; }; D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowApp.swift; sourceTree = ""; }; - D05B9F7729E39EEC008CB1F9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + D05B9F7729E39EEC008CB1F9 /* TunnelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelView.swift; sourceTree = ""; }; D05B9F7929E39EED008CB1F9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D0B98FBF29FD8072004E7149 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = ""; }; + D0B98FC629FDC5B5004E7149 /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; D0B98FD829FDDB6F004E7149 /* libburrow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libburrow.h; sourceTree = ""; }; D0B98FDC29FDDDCF004E7149 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; + D0BCC5FC2A086D4700AD070D /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = ""; }; D0BCC5FE2A086E1C00AD070D /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; D0BCC6032A09535900AD070D /* libburrow.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libburrow.a; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -288,8 +293,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D05B9F7829E39EEC008CB1F9 /* ContentView.swift in Sources */, + D0BCC6082A0981FE00AD070D /* Tunnel.swift in Sources */, + D05B9F7829E39EEC008CB1F9 /* TunnelView.swift in Sources */, + D0BCC5FF2A086E1C00AD070D /* Status.swift in Sources */, D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */, + D0BCC5FD2A086D4700AD070D /* NetworkExtension+Async.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index c8a87cf..1ac4a3b 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -3,27 +3,23 @@ import NetworkExtension class PacketTunnelProvider: NEPacketTunnelProvider { override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { - // Add code here to start the process of connecting the tunnel. + completionHandler(nil) } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - // Add code here to start the process of stopping the tunnel. completionHandler() } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { - // Add code here to handle the message. if let handler = completionHandler { handler(messageData) } } override func sleep(completionHandler: @escaping () -> Void) { - // Add code here to get ready to sleep. completionHandler() } override func wake() { - // Add code here to wake up. } }