From c9f085c10249f803439b236dab975640e0fa2dc2 Mon Sep 17 00:00:00 2001 From: Jamil Date: Fri, 28 Feb 2025 04:35:26 +0000 Subject: [PATCH] refactor(apple): Split IPC and VPN config into separate classes (#8279) The current `VPNConfigurationManager` class is too large and handles 3 separate things: - Encoding IPC messages - Sending IPC messages - Loading/storing the VPN configuration With this PR, these are split out into: - ProviderMessage class - IPCClient class - VPNConfigurationManager class These are then use directly from our `Store` in order to load their state upon `init()`, set the relevant properties, and thus views are updated accordingly. A couple minor bugs are fixed as well as part of the refactor. ### Tested: macOS - [x] Sign in - [x] Sign out - [x] Yanking the VPN configuration while signed in / out - [x] Yanking the system extension while signed in / out - [x] Denying the VPN configuration - [x] Denying Notifications - [x] Denying System Extension ### Tested: iOS - [x] Sign in - [x] Sign out - [x] Yanking the VPN configuration while signed in / out - [x] Yanking the system extension while signed in / out - [x] Denying the VPN configuration - [x] Denying Notifications --------- Signed-off-by: Jamil --- .../Firezone/Application/FirezoneApp.swift | 1 + .../FirezoneKit/Helpers/IPCClient.swift | 263 +++++++++ .../Sources/FirezoneKit/Helpers/Log.swift | 4 +- .../FirezoneKit/Helpers/LogExporter.swift | 4 +- .../Managers/VPNConfigurationManager.swift | 504 +++--------------- .../FirezoneKit/Models/ProviderMessage.swift | 77 +++ .../Sources/FirezoneKit/Models/Settings.swift | 16 +- .../Sources/FirezoneKit/Stores/Store.swift | 238 +++++---- .../Sources/FirezoneKit/Views/AppView.swift | 23 + .../FirezoneKit/Views/GrantVPNView.swift | 27 +- .../Sources/FirezoneKit/Views/MenuBar.swift | 26 +- .../FirezoneKit/Views/ResourceView.swift | 4 +- .../FirezoneKit/Views/SessionView.swift | 2 +- .../FirezoneKit/Views/SettingsView.swift | 16 +- .../FirezoneKit/Views/macOSAlert.swift | 7 +- .../PacketTunnelProvider.swift | 12 +- 16 files changed, 655 insertions(+), 569 deletions(-) create mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift create mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index d9c0f3507..594290803 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -76,6 +76,7 @@ struct FirezoneApp: App { func applicationDidFinishLaunching(_: Notification) { if let store { menuBar = MenuBar(store: store) + AppView.subscribeToGlobalEvents(store: store) } // SwiftUI will show the first window group, so close it on launch diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift new file mode 100644 index 000000000..854f1b684 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift @@ -0,0 +1,263 @@ +// +// IPCClient.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +import CryptoKit +import Foundation +import NetworkExtension + +class IPCClient { + enum Error: Swift.Error { + case invalidNotification + case decodeIPCDataFailed + case noIPCData + case invalidStatus(NEVPNStatus) + + var localizedDescription: String { + switch self { + case .invalidNotification: + return "NEVPNStatusDidChange notification doesn't seem to be valid." + case .decodeIPCDataFailed: + return "Decoding IPC data failed." + case .noIPCData: + return "No IPC data returned from the XPC connection!" + case .invalidStatus(let status): + return "The IPC operation couldn't complete because the VPN status is \(status)." + } + } + } + + // IPC only makes sense if there's a valid session. Session in this case refers to the `connection` field of + // the NETunnelProviderManager instance. + let session: NETunnelProviderSession + + // Track the "version" of the resource list so we can more efficiently + // retrieve it from the Provider + var resourceListHash = Data() + + // Cache resources on this side of the IPC barrier so we can + // return them to callers when they haven't changed. + var resourcesListCache: ResourceList = ResourceList.loading + + init(session: NETunnelProviderSession) { + self.session = session + } + + // Encoder used to send messages to the tunnel + let encoder = { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + + return encoder + }() + + func start(token: String? = nil) throws { + var options: [String: NSObject] = [:] + + // Pass token if provided + if let token = token { + options.merge(["token": token as NSObject]) { _, new in new } + } + + // Pass pre-1.4.0 Firezone ID if it exists. Pre 1.4.0 clients will have this + // persisted to the app side container URL. + if let id = FirezoneId.load(.pre140) { + options.merge(["id": id as NSObject]) { _, new in new } + } + + try session().startTunnel(options: options) + } + + func signOut() throws { + try session([.connected, .connecting, .reasserting]).stopTunnel() + try session().sendProviderMessage(encoder.encode(ProviderMessage.signOut)) + } + + func stop() throws { + try session([.connected, .connecting, .reasserting]).stopTunnel() + } + + func toggleInternetResource(enabled: Bool) throws { + try session([.connected]).sendProviderMessage( + encoder.encode(ProviderMessage.internetResourceEnabled(enabled))) + } + + func fetchResources() async throws -> ResourceList { + return try await withCheckedThrowingContinuation { continuation in + do { + // Request list of resources from the provider. We send the hash of the resource list we already have. + // If it differs, we'll get the full list in the callback. If not, we'll get nil. + try session([.connected]).sendProviderMessage( + encoder.encode(ProviderMessage.getResourceList(resourceListHash))) { data in + guard let data = data + else { + // No data returned; Resources haven't changed + continuation.resume(returning: self.resourcesListCache) + + return + } + + // Save hash to compare against + self.resourceListHash = Data(SHA256.hash(data: data)) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + do { + let decoded = try decoder.decode([Resource].self, from: data) + self.resourcesListCache = ResourceList.loaded(decoded) + + continuation.resume(returning: self.resourcesListCache) + } catch { + continuation.resume(throwing: error) + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + + func clearLogs() async throws { + return try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage(encoder.encode(ProviderMessage.clearLogs)) { _ in + continuation.resume() + } + } catch { + continuation.resume(throwing: error) + } + } + } + + func getLogFolderSize() async throws -> Int64 { + return try await withCheckedThrowingContinuation { continuation in + + do { + try session().sendProviderMessage( + encoder.encode(ProviderMessage.getLogFolderSize) + ) { data in + + guard let data = data + else { + continuation + .resume(throwing: Error.noIPCData) + + return + } + data.withUnsafeBytes { rawBuffer in + continuation.resume(returning: rawBuffer.load(as: Int64.self)) + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + + // Call this with a closure that will append each chunk to a buffer + // of some sort, like a file. The completed buffer is a valid Apple Archive + // in AAR format. + func exportLogs( + appender: @escaping (LogChunk) -> Void, + errorHandler: @escaping (Error) -> Void + ) { + let decoder = PropertyListDecoder() + + func loop() { + do { + try session().sendProviderMessage( + encoder.encode(ProviderMessage.exportLogs) + ) { data in + guard let data = data + else { + errorHandler(Error.noIPCData) + + return + } + + guard let chunk = try? decoder.decode( + LogChunk.self, from: data + ) + else { + errorHandler(Error.decodeIPCDataFailed) + + return + } + + appender(chunk) + + if !chunk.done { + // Continue + loop() + } + } + } catch { + Log.error(error) + } + } + + // Start exporting + loop() + } + + func consumeStopReason() async throws -> NEProviderStopReason? { + return try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage( + encoder.encode(ProviderMessage.consumeStopReason) + ) { data in + + guard let data = data, + let reason = String(data: data, encoding: .utf8), + let rawValue = Int(reason) + else { + continuation.resume(returning: nil) + + return + } + + continuation.resume(returning: NEProviderStopReason(rawValue: rawValue)) + } + } catch { + continuation.resume(throwing: error) + } + } + } + + // Subscribe to system notifications about our VPN status changing + // and let our handler know about them. + func subscribeToVPNStatusUpdates(handler: @escaping @MainActor (NEVPNStatus) async throws -> Void) { + Task { + for await notification in NotificationCenter.default.notifications(named: .NEVPNStatusDidChange) { + guard let session = notification.object as? NETunnelProviderSession + else { + Log.error(Error.invalidNotification) + return + } + + if session.status == .disconnected { + // Reset resource list + resourceListHash = Data() + resourcesListCache = ResourceList.loading + } + + do { try await handler(session.status) } catch { Log.error(error) } + } + } + } + + func sessionStatus() -> NEVPNStatus { + return session.status + } + + private func session(_ requiredStatuses: Set = []) throws -> NETunnelProviderSession { + if requiredStatuses.isEmpty || requiredStatuses.contains(session.status) { + return session + } + + throw Error.invalidStatus(session.status) + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift index a823b13b6..fb346f16a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift @@ -105,8 +105,8 @@ public final class Log { // because these happen often due to code signing requirements. private static func shouldCaptureError(_ err: Error) -> Bool { #if DEBUG - if let err = err as? VPNConfigurationManagerError, - case VPNConfigurationManagerError.noIPCData = err { + if let err = err as? IPCClient.Error, + case IPCClient.Error.noIPCData = err { return false } #endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/LogExporter.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/LogExporter.swift index a24253ed0..9fb65edc1 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/LogExporter.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/LogExporter.swift @@ -28,7 +28,7 @@ enum LogExporter { static func export( to archiveURL: URL, - with vpnConfigurationManager: VPNConfigurationManager + with ipcClient: IPCClient ) async throws { guard let logFolderURL = SharedAccess.logFolderURL else { @@ -53,7 +53,7 @@ enum LogExporter { // 3. Await tunnel log export from tunnel process try await withCheckedThrowingContinuation { continuation in - vpnConfigurationManager.exportLogs( + ipcClient.exportLogs( appender: { chunk in do { // Append each chunk to the archive diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift index 6dfaf99b2..029cc0f56 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift @@ -7,151 +7,41 @@ // Abstracts the nitty gritty of loading and saving to our // VPN configuration in system preferences. -// TODO: Refactor to fix file length -// swiftlint:disable file_length - -import CryptoKit import Foundation import NetworkExtension enum VPNConfigurationManagerError: Error { case managerNotInitialized - case cannotLoad - case decodeIPCDataFailed - case invalidNotification - case noIPCData - case invalidStatus(NEVPNStatus) + case savedProtocolConfigurationIsInvalid var localizedDescription: String { switch self { case .managerNotInitialized: - return "Manager doesn't seem initialized." - case .decodeIPCDataFailed: - return "Decoding IPC data failed." - case .invalidNotification: - return "NEVPNStatusDidChange notification doesn't seem to be valid." - case .cannotLoad: - return "Could not load VPN configurations!" - case .noIPCData: - return "No IPC data returned from the XPC connection!" - case .invalidStatus(let status): - return "The IPC operation couldn't complete because the VPN status is \(status)." + return "NETunnelProviderManager is not yet initialized. Race condition?" + case .savedProtocolConfigurationIsInvalid: + return "Saved protocol configuration is invalid. Check types?" } } } -public enum VPNConfigurationManagerKeys { - static let actorName = "actorName" - static let authBaseURL = "authBaseURL" - static let apiURL = "apiURL" - public static let accountSlug = "accountSlug" - public static let logFilter = "logFilter" - public static let internetResourceEnabled = "internetResourceEnabled" -} - -public enum TunnelMessage: Codable { - case getResourceList(Data) - case signOut - case internetResourceEnabled(Bool) - case clearLogs - case getLogFolderSize - case exportLogs - case consumeStopReason - - enum CodingKeys: String, CodingKey { - case type - case value - } - - enum MessageType: String, Codable { - case getResourceList - case signOut - case internetResourceEnabled - case clearLogs - case getLogFolderSize - case exportLogs - case consumeStopReason - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(MessageType.self, forKey: .type) - switch type { - case .internetResourceEnabled: - let value = try container.decode(Bool.self, forKey: .value) - self = .internetResourceEnabled(value) - case .getResourceList: - let value = try container.decode(Data.self, forKey: .value) - self = .getResourceList(value) - case .signOut: - self = .signOut - case .clearLogs: - self = .clearLogs - case .getLogFolderSize: - self = .getLogFolderSize - case .exportLogs: - self = .exportLogs - case .consumeStopReason: - self = .consumeStopReason - } - } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .internetResourceEnabled(let value): - try container.encode(MessageType.internetResourceEnabled, forKey: .type) - try container.encode(value, forKey: .value) - case .getResourceList(let value): - try container.encode(MessageType.getResourceList, forKey: .type) - try container.encode(value, forKey: .value) - case .signOut: - try container.encode(MessageType.signOut, forKey: .type) - case .clearLogs: - try container.encode(MessageType.clearLogs, forKey: .type) - case .getLogFolderSize: - try container.encode(MessageType.getLogFolderSize, forKey: .type) - case .exportLogs: - try container.encode(MessageType.exportLogs, forKey: .type) - case .consumeStopReason: - try container.encode(MessageType.consumeStopReason, forKey: .type) - } - } -} - -// TODO: Refactor this to remove the lint ignore -// swiftlint:disable:next type_body_length public class VPNConfigurationManager { - - // Connect status updates with our listeners - private var tunnelObservingTasks: [Task] = [] - - // Track the "version" of the resource list so we can more efficiently - // retrieve it from the Provider - private var resourceListHash = Data() - - // Cache resources on this side of the IPC barrier so we can - // return them to callers when they haven't changed. - private var resourcesListCache: ResourceList = ResourceList.loading + public enum Keys { + static let actorName = "actorName" + static let authBaseURL = "authBaseURL" + static let apiURL = "apiURL" + public static let accountSlug = "accountSlug" + public static let logFilter = "logFilter" + public static let internetResourceEnabled = "internetResourceEnabled" + } // Persists our tunnel settings - private var manager: NETunnelProviderManager? - - // Indicates if the internet resource is currently enabled - public var internetResourceEnabled: Bool = false - - // Encoder used to send messages to the tunnel - private let encoder = { - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - - return encoder - }() + let manager: NETunnelProviderManager public static let bundleIdentifier: String = "\(Bundle.main.bundleIdentifier!).network-extension" - private let bundleDescription = "Firezone" + static let bundleDescription = "Firezone" // Initialize and save a new VPN configuration in system Preferences - func create() async throws { + init() async throws { let protocolConfiguration = NETunnelProviderProtocol() let manager = NETunnelProviderManager() let settings = Settings.defaultValue @@ -159,119 +49,100 @@ public class VPNConfigurationManager { protocolConfiguration.providerConfiguration = settings.toProviderConfiguration() protocolConfiguration.providerBundleIdentifier = VPNConfigurationManager.bundleIdentifier protocolConfiguration.serverAddress = settings.apiURL - manager.localizedDescription = bundleDescription + manager.localizedDescription = VPNConfigurationManager.bundleDescription manager.protocolConfiguration = protocolConfiguration - // Save the new VPN configuration to System Preferences and reload it, - // which should update our status from nil -> disconnected. - // If the user denied the operation, the status will be .invalid - do { - try await manager.saveToPreferences() - try await manager.loadFromPreferences() - self.manager = manager - } catch let error as NSError { - if error.domain == "NEVPNErrorDomain" && error.code == 5 { - // Silence error when the user doesn't click "Allow" on the VPN - // permission dialog - Log.info("VPN permission was denied by the user") + try await manager.saveToPreferences() + try await manager.loadFromPreferences() - return - } - - throw error - } + self.manager = manager } - func loadFromPreferences( - vpnStateUpdateHandler: @escaping @MainActor (NEVPNStatus, Settings?, String?, NEProviderStopReason?) async -> Void - ) async throws { + init(from manager: NETunnelProviderManager) { + self.manager = manager + } + + static func load() async throws -> VPNConfigurationManager? { // loadAllFromPreferences() returns list of VPN configurations created by our main app's bundle ID. // Since our bundle ID can change (by us), find the one that's current and ignore the others. let managers = try await NETunnelProviderManager.loadAllFromPreferences() Log.log("\(#function): \(managers.count) tunnel managers found") for manager in managers where manager.localizedDescription == bundleDescription { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.cannotLoad - } - - // Update our state - self.manager = manager - - let settings = Settings.fromProviderConfiguration(providerConfiguration) - let actorName = providerConfiguration[VPNConfigurationManagerKeys.actorName] - if let internetResourceEnabled = providerConfiguration[ - VPNConfigurationManagerKeys.internetResourceEnabled - ]?.data(using: .utf8) { - - self.internetResourceEnabled = (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabled)) ?? false - - } - let status = manager.connection.status - - // Configure our Telemetry environment - Telemetry.setEnvironmentOrClose(settings.apiURL) - Telemetry.accountSlug = providerConfiguration[VPNConfigurationManagerKeys.accountSlug] - - // Share what we found with our caller - await vpnStateUpdateHandler(status, settings, actorName, nil) - - // Stop looking for our tunnel - break + return VPNConfigurationManager(from: manager) } - // If no tunnel configuration was found, update state to - // prompt user to create one. - if manager == nil { - await vpnStateUpdateHandler(.invalid, nil, nil, nil) - } - - // Hook up status updates - subscribeToVPNStatusUpdates(handler: vpnStateUpdateHandler) + return nil } - func saveAuthResponse(_ authResponse: AuthResponse) async throws { - guard let manager = manager, - let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - var providerConfiguration = protocolConfiguration.providerConfiguration + func actorName() throws -> String? { + guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] else { - throw VPNConfigurationManagerError.managerNotInitialized + throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid } - providerConfiguration[VPNConfigurationManagerKeys.actorName] = authResponse.actorName - providerConfiguration[VPNConfigurationManagerKeys.accountSlug] = authResponse.accountSlug + return providerConfiguration[Keys.actorName] + } + + func internetResourceEnabled() throws -> Bool? { + guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] + else { + throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid + } + + // TODO: Store Bool directly in VPN Configuration + if providerConfiguration[Keys.internetResourceEnabled] == "true" { + return true + } + + if providerConfiguration[Keys.internetResourceEnabled] == "false" { + return false + } + + return nil + } + + func save(authResponse: AuthResponse) async throws { + guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + var providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] + else { + throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid + } + + providerConfiguration[Keys.actorName] = authResponse.actorName + providerConfiguration[Keys.accountSlug] = authResponse.accountSlug + + // Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure. + Telemetry.accountSlug = providerConfiguration[Keys.accountSlug] + protocolConfiguration.providerConfiguration = providerConfiguration manager.protocolConfiguration = protocolConfiguration - // We always set this to true when starting the tunnel in case our tunnel - // was disabled by the system for some reason. + // Always set this to true when starting the tunnel in case our tunnel was disabled by the system. manager.isEnabled = true try await manager.saveToPreferences() try await manager.loadFromPreferences() } - func saveSettings(_ settings: Settings) async throws { - guard let manager = manager, - let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + func save(settings: Settings) async throws { + guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] else { - throw VPNConfigurationManagerError.managerNotInitialized + throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid } var newProviderConfiguration = settings.toProviderConfiguration() // Don't clobber existing actorName - newProviderConfiguration[VPNConfigurationManagerKeys.actorName] = - providerConfiguration[VPNConfigurationManagerKeys.actorName] + newProviderConfiguration[Keys.actorName] = providerConfiguration[Keys.actorName] + protocolConfiguration.providerConfiguration = newProviderConfiguration protocolConfiguration.serverAddress = settings.apiURL manager.protocolConfiguration = protocolConfiguration - // We always set this to true when starting the tunnel in case our tunnel - // was disabled by the system for some reason. manager.isEnabled = true try await manager.saveToPreferences() @@ -281,236 +152,17 @@ public class VPNConfigurationManager { Telemetry.setEnvironmentOrClose(settings.apiURL) } - func start(token: String? = nil) throws { - var options: [String: NSObject] = [:] - - // Pass token if provided - if let token = token { - options.merge(["token": token as NSObject]) { _, new in new } - } - - // Pass pre-1.4.0 Firezone ID if it exists. Pre 1.4.0 clients will have this - // persisted to the app side container URL. - if let id = FirezoneId.load(.pre140) { - options.merge(["id": id as NSObject]) { _, new in new } - } - - try session().startTunnel(options: options) - } - - func signOut() throws { - try session([.connected, .connecting, .reasserting]).stopTunnel() - try session().sendProviderMessage(encoder.encode(TunnelMessage.signOut)) - } - - func stop() throws { - try session([.connected, .connecting, .reasserting]).stopTunnel() - } - - func updateInternetResourceState() throws { - try session([.connected]).sendProviderMessage( - encoder.encode(TunnelMessage.internetResourceEnabled(internetResourceEnabled))) - } - - func toggleInternetResource(enabled: Bool) throws { - internetResourceEnabled = enabled - try updateInternetResourceState() - } - - func fetchResources() async throws -> ResourceList { - return try await withCheckedThrowingContinuation { continuation in - do { - // Request list of resources from the provider. We send the hash of the resource list we already have. - // If it differs, we'll get the full list in the callback. If not, we'll get nil. - try session([.connected]).sendProviderMessage( - encoder.encode(TunnelMessage.getResourceList(resourceListHash))) { data in - - guard let data = data - else { - // No data returned; Resources haven't changed - continuation.resume(returning: self.resourcesListCache) - - return - } - - // Save hash to compare against - self.resourceListHash = Data(SHA256.hash(data: data)) - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - - do { - let decoded = try decoder.decode([Resource].self, from: data) - self.resourcesListCache = ResourceList.loaded(decoded) - - continuation.resume(returning: self.resourcesListCache) - } catch { - continuation.resume(throwing: error) - } - } - } catch { - continuation.resume(throwing: error) - } - } - } - - func clearLogs() async throws { - return try await withCheckedThrowingContinuation { continuation in - do { - try session().sendProviderMessage(encoder.encode(TunnelMessage.clearLogs)) { _ in - continuation.resume() - } - } catch { - continuation.resume(throwing: error) - } - } - } - - func getLogFolderSize() async throws -> Int64 { - return try await withCheckedThrowingContinuation { continuation in - - do { - try session().sendProviderMessage( - encoder.encode(TunnelMessage.getLogFolderSize) - ) { data in - - guard let data = data - else { - continuation - .resume(throwing: VPNConfigurationManagerError.noIPCData) - - return - } - data.withUnsafeBytes { rawBuffer in - continuation.resume(returning: rawBuffer.load(as: Int64.self)) - } - } - } catch { - continuation.resume(throwing: error) - } - } - } - - // Call this with a closure that will append each chunk to a buffer - // of some sort, like a file. The completed buffer is a valid Apple Archive - // in AAR format. - func exportLogs( - appender: @escaping (LogChunk) -> Void, - errorHandler: @escaping (VPNConfigurationManagerError) -> Void - ) { - let decoder = PropertyListDecoder() - - func loop() { - do { - try session().sendProviderMessage( - encoder.encode(TunnelMessage.exportLogs) - ) { data in - guard let data = data - else { - errorHandler(VPNConfigurationManagerError.noIPCData) - - return - } - - guard let chunk = try? decoder.decode( - LogChunk.self, from: data - ) - else { - errorHandler(VPNConfigurationManagerError.decodeIPCDataFailed) - - return - } - - appender(chunk) - - if !chunk.done { - // Continue - loop() - } - } - } catch { - Log.error(error) - } - } - - // Start exporting - loop() - } - - func consumeStopReason() async throws -> NEProviderStopReason? { - return try await withCheckedThrowingContinuation { continuation in - do { - try session().sendProviderMessage( - encoder.encode(TunnelMessage.consumeStopReason) - ) { data in - - guard let data = data, - let reason = String(data: data, encoding: .utf8), - let rawValue = Int(reason) - else { - continuation.resume(returning: nil) - - return - } - - continuation.resume(returning: NEProviderStopReason(rawValue: rawValue)) - } - } catch { - continuation.resume(throwing: error) - } - } - } - - private func session(_ requiredStatuses: Set = []) throws -> NETunnelProviderSession { - guard let session = manager?.connection as? NETunnelProviderSession + func settings() throws -> Settings { + guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] else { - throw VPNConfigurationManagerError.managerNotInitialized + throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid } - if requiredStatuses.isEmpty || requiredStatuses.contains(session.status) { - return session - } - - throw VPNConfigurationManagerError.invalidStatus(session.status) + return Settings.fromProviderConfiguration(providerConfiguration) } - // Subscribe to system notifications about our VPN status changing - // and let our handler know about them. - private func subscribeToVPNStatusUpdates( - handler: @escaping @MainActor (NEVPNStatus, Settings?, String?, NEProviderStopReason? - ) async -> Void) { - Log.log("\(#function)") - - for task in tunnelObservingTasks { - task.cancel() - } - tunnelObservingTasks.removeAll() - - tunnelObservingTasks.append( - Task { - for await notification in NotificationCenter.default.notifications( - named: .NEVPNStatusDidChange - ) { - guard let session = notification.object as? NETunnelProviderSession - else { - Log.error(VPNConfigurationManagerError.invalidNotification) - return - } - - var reason: NEProviderStopReason? - - if session.status == .disconnected { - // Reset resource list - resourceListHash = Data() - resourcesListCache = ResourceList.loading - - // Attempt to consume the last stopped reason - do { reason = try await consumeStopReason() } catch { Log.error(error) } - } - - await handler(session.status, nil, nil, reason) - } - } - ) + func session() -> NETunnelProviderSession? { + return manager.connection as? NETunnelProviderSession } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift new file mode 100644 index 000000000..4a6cd16cb --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift @@ -0,0 +1,77 @@ +// +// ProviderMessage.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// +// Encodes / Decodes messages to the provider service. + +import Foundation + +public enum ProviderMessage: Codable { + case getResourceList(Data) + case signOut + case internetResourceEnabled(Bool) + case clearLogs + case getLogFolderSize + case exportLogs + case consumeStopReason + + enum CodingKeys: String, CodingKey { + case type + case value + } + + enum MessageType: String, Codable { + case getResourceList + case signOut + case internetResourceEnabled + case clearLogs + case getLogFolderSize + case exportLogs + case consumeStopReason + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(MessageType.self, forKey: .type) + switch type { + case .internetResourceEnabled: + let value = try container.decode(Bool.self, forKey: .value) + self = .internetResourceEnabled(value) + case .getResourceList: + let value = try container.decode(Data.self, forKey: .value) + self = .getResourceList(value) + case .signOut: + self = .signOut + case .clearLogs: + self = .clearLogs + case .getLogFolderSize: + self = .getLogFolderSize + case .exportLogs: + self = .exportLogs + case .consumeStopReason: + self = .consumeStopReason + } + } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .internetResourceEnabled(let value): + try container.encode(MessageType.internetResourceEnabled, forKey: .type) + try container.encode(value, forKey: .value) + case .getResourceList(let value): + try container.encode(MessageType.getResourceList, forKey: .type) + try container.encode(value, forKey: .value) + case .signOut: + try container.encode(MessageType.signOut, forKey: .type) + case .clearLogs: + try container.encode(MessageType.clearLogs, forKey: .type) + case .getLogFolderSize: + try container.encode(MessageType.getLogFolderSize, forKey: .type) + case .exportLogs: + try container.encode(MessageType.exportLogs, forKey: .type) + case .consumeStopReason: + try container.encode(MessageType.consumeStopReason, forKey: .type) + } + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift index 15d693ebe..ce9e61b42 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift @@ -31,14 +31,14 @@ struct Settings: Equatable { static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings { if let providerConfiguration = providerConfiguration as? [String: String] { return Settings( - authBaseURL: providerConfiguration[VPNConfigurationManagerKeys.authBaseURL] + authBaseURL: providerConfiguration[VPNConfigurationManager.Keys.authBaseURL] ?? Settings.defaultValue.authBaseURL, - apiURL: providerConfiguration[VPNConfigurationManagerKeys.apiURL] + apiURL: providerConfiguration[VPNConfigurationManager.Keys.apiURL] ?? Settings.defaultValue.apiURL, - logFilter: providerConfiguration[VPNConfigurationManagerKeys.logFilter] + logFilter: providerConfiguration[VPNConfigurationManager.Keys.logFilter] ?? Settings.defaultValue.logFilter, internetResourceEnabled: getInternetResourceEnabled( - internetResourceEnabled: providerConfiguration[VPNConfigurationManagerKeys.internetResourceEnabled]) + internetResourceEnabled: providerConfiguration[VPNConfigurationManager.Keys.internetResourceEnabled]) ) } else { return Settings.defaultValue @@ -62,10 +62,10 @@ struct Settings: Equatable { } return [ - VPNConfigurationManagerKeys.authBaseURL: authBaseURL, - VPNConfigurationManagerKeys.apiURL: apiURL, - VPNConfigurationManagerKeys.logFilter: logFilter, - VPNConfigurationManagerKeys.internetResourceEnabled: string + VPNConfigurationManager.Keys.authBaseURL: authBaseURL, + VPNConfigurationManager.Keys.apiURL: apiURL, + VPNConfigurationManager.Keys.logFilter: logFilter, + VPNConfigurationManager.Keys.internetResourceEnabled: string ] } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 06b4ce7ae..63802a894 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -20,7 +20,7 @@ public final class Store: ObservableObject { @Published private(set) var actorName: String? // Make our tunnel configuration convenient for SettingsView to consume - @Published var settings: Settings + @Published private(set) var settings = Settings.defaultValue // Enacapsulate Tunnel status here to make it easier for other components // to observe @@ -28,54 +28,61 @@ public final class Store: ObservableObject { @Published private(set) var decision: UNAuthorizationStatus? + @Published private(set) var internetResourceEnabled: Bool? + #if os(macOS) // Track whether our system extension has been installed (macOS) @Published private(set) var systemExtensionStatus: SystemExtensionStatus? #endif - let vpnConfigurationManager: VPNConfigurationManager - private var sessionNotification: SessionNotification - private var cancellables: Set = [] + let sessionNotification = SessionNotification() + private var resourcesTimer: Timer? + private var resourceUpdateTask: Task? + + private var vpnConfigurationManager: VPNConfigurationManager? public init() { - // Initialize all stored properties - self.settings = Settings.defaultValue - self.sessionNotification = SessionNotification() - self.vpnConfigurationManager = VPNConfigurationManager() - self.sessionNotification.signInHandler = { Task { do { try await WebAuthSession.signIn(store: self) } catch { Log.error(error) } } } + // Load our state from the system. Based on what's loaded, we may need to ask the user for permission for things. + initNotifications() + initSystemExtension() + initVPNConfiguration() + } + + func initNotifications() { Task { - // Load user's decision whether to allow / disallow notifications self.decision = await self.sessionNotification.loadAuthorizationStatus() + } + } - // Load VPN configuration and system extension status - do { - try await self.bindToVPNConfigurationUpdates() - let vpnConfigurationStatus = self.status - + func initSystemExtension() { #if os(macOS) - let systemExtensionStatus = try await self.checkedSystemExtensionStatus() - - if systemExtensionStatus != .installed - || vpnConfigurationStatus == .invalid { - - // Show the main Window if VPN permission needs to be granted - AppView.WindowDefinition.main.openWindow() - } else { - AppView.WindowDefinition.main.window()?.close() - } + Task { + do { + self.systemExtensionStatus = try await self.checkSystemExtensionStatus() + } catch { + Log.error(error) + } + } #endif + } - if vpnConfigurationStatus == .disconnected { - - // Try to connect on start - try self.vpnConfigurationManager.start() + func initVPNConfiguration() { + Task { + do { + // Try to load existing configuration + if let manager = try await VPNConfigurationManager.load() { + self.vpnConfigurationManager = manager + self.settings = try manager.settings() + try await setupTunnelObservers(autoStart: true) + } else { + status = .invalid } } catch { Log.error(error) @@ -83,56 +90,65 @@ public final class Store: ObservableObject { } } - public func internetResourceEnabled() -> Bool { - self.vpnConfigurationManager.internetResourceEnabled + func setupTunnelObservers(autoStart: Bool = false) async throws { + let statusChangeHandler: (NEVPNStatus) async throws -> Void = { [weak self] status in + try await self?.handleStatusChange(newStatus: status) + } + + try ipcClient().subscribeToVPNStatusUpdates(handler: statusChangeHandler) + + if autoStart && status == .disconnected { + // Try to connect on start + try ipcClient().start() + } + + try await handleStatusChange(newStatus: ipcClient().sessionStatus()) } - func bindToVPNConfigurationUpdates() async throws { - // Load our existing VPN configuration and set an update handler - try await self.vpnConfigurationManager.loadFromPreferences( - vpnStateUpdateHandler: { @MainActor [weak self] status, settings, actorName, stopReason in - guard let self else { return } + func handleStatusChange(newStatus: NEVPNStatus) async throws { + status = newStatus - self.status = status + if status == .connected { + // Load saved actorName + actorName = try? manager().actorName() - if let settings { - self.settings = settings - } + // Load saved internet resource status + internetResourceEnabled = try? manager().internetResourceEnabled() - if let actorName { - self.actorName = actorName - } - - if status == .connected { - self.beginUpdatingResources { resourceList in - self.resourceList = resourceList - } - } - - if status == .disconnected { - self.endUpdatingResources() - self.resourceList = ResourceList.loading - } + // Load Resources + beginUpdatingResources() + } else { + endUpdatingResources() + } #if os(macOS) - // On macOS we must show notifications from the UI process. On iOS, we've already initiated the notification - // from the tunnel process, because the UI process is not guaranteed to be alive. - if status == .disconnected, - stopReason == .authenticationCanceled { + // On macOS we must show notifications from the UI process. On iOS, we've already initiated the notification + // from the tunnel process, because the UI process is not guaranteed to be alive. + if status == .disconnected { + do { + let reason = try await ipcClient().consumeStopReason() + if reason == .authenticationCanceled { await self.sessionNotification.showSignedOutAlertmacOS() } -#endif + } catch { + Log.error(error) } - ) + } + + // When this happens, it's because either our VPN configuration or System Extension (or both) were removed. + // So load the system extension status again to determine which view to load. + if status == .invalid { + self.systemExtensionStatus = try await checkSystemExtensionStatus() + } +#endif } #if os(macOS) - func checkedSystemExtensionStatus() async throws -> SystemExtensionStatus { + func checkSystemExtensionStatus() async throws -> SystemExtensionStatus { let checker = SystemExtensionManager() let status = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - checker.checkStatus( identifier: VPNConfigurationManager.bundleIdentifier, continuation: continuation @@ -145,8 +161,6 @@ public final class Store: ObservableObject { try await installSystemExtension() } - self.systemExtensionStatus = status - return status } @@ -157,7 +171,6 @@ public final class Store: ObservableObject { // See https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers self.systemExtensionStatus = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - installer.installSystemExtension( identifier: VPNConfigurationManager.bundleIdentifier, continuation: continuation @@ -166,12 +179,29 @@ public final class Store: ObservableObject { } #endif - func grantVPNPermission() async throws { + func installVPNConfiguration() async throws { // Create a new VPN configuration in system settings. - try await self.vpnConfigurationManager.create() + self.vpnConfigurationManager = try await VPNConfigurationManager() - // Reload our state - try await bindToVPNConfigurationUpdates() + try await setupTunnelObservers() + } + + func ipcClient() throws -> IPCClient { + guard let session = try manager().session() + else { + throw VPNConfigurationManagerError.managerNotInitialized + } + + return IPCClient(session: session) + } + + func manager() throws -> VPNConfigurationManager { + guard let vpnConfigurationManager + else { + throw VPNConfigurationManagerError.managerNotInitialized + } + + return vpnConfigurationManager } func grantNotifications() async throws { @@ -182,34 +212,48 @@ public final class Store: ObservableObject { return URL(string: settings.authBaseURL) } - private func start(token: String? = nil) throws { - try self.vpnConfigurationManager.start(token: token) - } - func stop() throws { - try self.vpnConfigurationManager.stop() + try ipcClient().stop() } func signIn(authResponse: AuthResponse) async throws { // Save actorName self.actorName = authResponse.actorName - try await self.vpnConfigurationManager.saveSettings(settings) - try await self.vpnConfigurationManager.saveAuthResponse(authResponse) + try await manager().save(authResponse: authResponse) // Bring the tunnel up and send it a token to start - try self.vpnConfigurationManager.start(token: authResponse.token) + try ipcClient().start(token: authResponse.token) } func signOut() throws { - try self.vpnConfigurationManager.signOut() + try ipcClient().signOut() + } + + func clearLogs() async throws { + try await ipcClient().clearLogs() + } + + func saveSettings(_ newSettings: Settings) async throws { + try await manager().save(settings: newSettings) + self.settings = newSettings + } + + func toggleInternetResource() async throws { + internetResourceEnabled = !(internetResourceEnabled ?? false) + settings.internetResourceEnabled = internetResourceEnabled + + try ipcClient().toggleInternetResource(enabled: internetResourceEnabled == true) + try await manager().save(settings: settings) + } + + private func start(token: String? = nil) throws { + try ipcClient().start(token: token) } // Network Extensions don't have a 2-way binding up to the GUI process, // so we need to periodically ask the tunnel process for them. - func beginUpdatingResources(callback: @escaping @MainActor (ResourceList) -> Void) { - Log.log("\(#function)") - + private func beginUpdatingResources() { if self.resourcesTimer != nil { // Prevent duplicate timer scheduling. This will happen if the system sends us two .connected status updates // in a row, which can happen occasionally. @@ -219,11 +263,17 @@ public final class Store: ObservableObject { // Define the Timer's closure let updateResources: @Sendable (Timer) -> Void = { _ in Task { - do { - let resources = try await self.vpnConfigurationManager.fetchResources() - await callback(resources) - } catch { - Log.error(error) + await MainActor.run { + self.resourceUpdateTask?.cancel() + self.resourceUpdateTask = Task { + if !Task.isCancelled { + do { + self.resourceList = try await self.ipcClient().fetchResources() + } catch { + Log.error(error) + } + } + } } } } @@ -240,20 +290,10 @@ public final class Store: ObservableObject { updateResources(timer) } - func endUpdatingResources() { + private func endUpdatingResources() { + resourceUpdateTask?.cancel() resourcesTimer?.invalidate() resourcesTimer = nil - } - - func save(_ newSettings: Settings) async throws { - try await self.vpnConfigurationManager.saveSettings(newSettings) - self.settings = newSettings - } - - func toggleInternetResource(enabled: Bool) async throws { - try self.vpnConfigurationManager.toggleInternetResource(enabled: enabled) - var newSettings = settings - newSettings.internetResourceEnabled = self.vpnConfigurationManager.internetResourceEnabled - try await save(newSettings) + resourceList = ResourceList.loading } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index a3ca8151b..8ee6a120a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -22,6 +22,29 @@ public struct AppView: View { @EnvironmentObject var store: Store #if os(macOS) + // This is a static function because the Environment Object is not present at initialization time when we want to + // subscribe the AppView to certain Store properties to control the main window lifecycle which SwiftUI doesn't + // handle. + private static var cancellables: Set = [] + public static func subscribeToGlobalEvents(store: Store) { + store.$status + .combineLatest(store.$systemExtensionStatus) + .receive(on: DispatchQueue.main) + .debounce(for: .seconds(0.3), scheduler: DispatchQueue.main) // Prevents flurry of windows from opening + .sink(receiveValue: { status, systemExtensionStatus in + // Open window in case permissions are revoked + if status == .invalid || systemExtensionStatus != .installed { + WindowDefinition.main.openWindow() + } + + // Close window upon launch for day-to-day use + if status != .invalid && systemExtensionStatus == .installed && FirezoneId.load(.pre140) != nil { + WindowDefinition.main.window()?.close() + } + }) + .store(in: &cancellables) + } + public enum WindowDefinition: String, CaseIterable { case main case settings diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift index 7996da5b1..d43831d00 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift @@ -8,6 +8,10 @@ import SwiftUI import Combine +#if os(macOS) +import SystemExtensions +#endif + struct GrantVPNView: View { @EnvironmentObject var store: Store @EnvironmentObject var errorHandler: GlobalErrorHandler @@ -36,7 +40,7 @@ struct GrantVPNView: View { .imageScale(.large) Spacer() Button("Grant VPN Permission") { - grantVPNPermission() + installVPNConfiguration() } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -108,7 +112,7 @@ struct GrantVPNView: View { Spacer() Button( action: { - grantVPNPermission() + installVPNConfiguration() }, label: { Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled") @@ -144,10 +148,21 @@ struct GrantVPNView: View { } } - func grantVPNPermission() { + func installVPNConfiguration() { Task { do { - try await store.grantVPNPermission() + try await store.installVPNConfiguration() + } catch let error as NSError { + if error.domain == "NEVPNErrorDomain" && error.code == 5 { + // Warn when the user doesn't click "Allow" on the VPN dialog + let alert = NSAlert() + alert.messageText = "Permission required." + alert.informativeText = + "Firezone requires permission to install VPN configurations. Without it, all functionality will be disabled." + _ = alert.runModal() + } else { + throw error + } } catch { Log.error(error) await macOSAlert.show(for: error) @@ -161,10 +176,10 @@ struct GrantVPNView: View { #endif #if os(iOS) - func grantVPNPermission() { + func installVPNConfiguration() { Task { do { - try await store.grantVPNPermission() + try await store.installVPNConfiguration() } catch { Log.error(error) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index c9d1844cf..8d75847d2 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -23,7 +23,7 @@ public final class MenuBar: NSObject, ObservableObject { var statusItem: NSStatusItem var lastShownFavorites: [Resource] = [] var lastShownOthers: [Resource] = [] - var wasInternetResourceEnabled: Bool = false + var wasInternetResourceEnabled: Bool? var cancellables: Set = [] var updateChecker: UpdateChecker = UpdateChecker() var updateMenuDisplayed: Bool = false @@ -285,7 +285,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownFavorites = newFavorites - wasInternetResourceEnabled = store.internetResourceEnabled() + wasInternetResourceEnabled = store.internetResourceEnabled } func populateOtherResourcesMenu(_ newOthers: [Resource]) { @@ -313,7 +313,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownOthers = newOthers - wasInternetResourceEnabled = store.internetResourceEnabled() + wasInternetResourceEnabled = store.internetResourceEnabled } func updateStatusItemIcon() { @@ -467,7 +467,7 @@ public final class MenuBar: NSObject, ObservableObject { return false } - return wasInternetResourceEnabled != store.internetResourceEnabled() + return wasInternetResourceEnabled != store.internetResourceEnabled } func refreshUpdateItem() { @@ -503,7 +503,7 @@ public final class MenuBar: NSObject, ObservableObject { } func internetResourceTitle(resource: Resource) -> String { - let status = store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled + let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled return status + " " + resource.name } @@ -526,7 +526,7 @@ public final class MenuBar: NSObject, ObservableObject { } func internetResourceToggleTitle() -> String { - store.internetResourceEnabled() ? "Disable this resource" : "Enable this resource" + store.internetResourceEnabled == true ? "Disable this resource" : "Enable this resource" } // TODO: Refactor this when refactoring for macOS 13 @@ -700,7 +700,17 @@ public final class MenuBar: NSObject, ObservableObject { // the system extension here too just in case. It's a no-op if already // installed. try await store.installSystemExtension() - try await store.grantVPNPermission() + try await store.installVPNConfiguration() + } catch let error as NSError { + if error.domain == "NEVPNErrorDomain" && error.code == 5 { + // Warn when the user doesn't click "Allow" on the VPN dialog + let alert = NSAlert() + alert.messageText = + "Firezone requires permission to install VPN configurations. Without it, all functionality will be disabled." + _ = alert.runModal() + } else { + throw error + } } catch { Log.error(error) await macOSAlert.show(for: error) @@ -756,7 +766,7 @@ public final class MenuBar: NSObject, ObservableObject { @objc func internetResourceToggle(_ sender: NSMenuItem) { Task { do { - try await store.toggleInternetResource(enabled: !store.internetResourceEnabled()) + try await store.toggleInternetResource() } catch { Log.error(error) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift index 60366302b..6e41b3e50 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift @@ -235,7 +235,7 @@ struct ToggleInternetResourceButton: View { @EnvironmentObject var store: Store private func toggleResourceEnabledText() -> String { - if store.internetResourceEnabled() { + if store.internetResourceEnabled == true { "Disable this resource" } else { "Enable this resource" @@ -247,7 +247,7 @@ struct ToggleInternetResourceButton: View { action: { Task { do { - try await store.toggleInternetResource(enabled: !store.internetResourceEnabled()) + try await store.toggleInternetResource() } catch { Log.error(error) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index ee1e773fc..5704404a2 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift @@ -81,7 +81,7 @@ struct ResourceSection: View { @EnvironmentObject var store: Store private func internetResourceTitle(resource: Resource) -> String { - let status = store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled + let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled return status + " " + resource.name } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index 3e2c7d315..04c70f0b8 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -522,13 +522,13 @@ public struct SettingsView: View { do { try await LogExporter.export( to: destinationURL, - with: store.vpnConfigurationManager + with: store.ipcClient() ) window.contentViewController?.presentingViewController?.dismiss(self) } catch { - if let error = error as? VPNConfigurationManagerError, - case VPNConfigurationManagerError.noIPCData = error { + if let error = error as? IPCClient.Error, + case IPCClient.Error.noIPCData = error { Log.warning("\(#function): Error exporting logs: \(error). Is the XPC service running?") } else { Log.error(error) @@ -584,7 +584,7 @@ public struct SettingsView: View { try self.store.signOut() } - try await store.save(settings) + try await store.saveSettings(settings) } catch { Log.error(error) } @@ -613,7 +613,7 @@ public struct SettingsView: View { do { #if os(macOS) - let providerLogFolderSize = try await store.vpnConfigurationManager.getLogFolderSize() + let providerLogFolderSize = try await store.ipcClient().getLogFolderSize() let totalSize = logFolderSize + providerLogFolderSize #else let totalSize = logFolderSize @@ -627,8 +627,8 @@ public struct SettingsView: View { return byteCountFormatter.string(fromByteCount: Int64(totalSize)) } catch { - if let error = error as? VPNConfigurationManagerError, - case VPNConfigurationManagerError.noIPCData = error { + if let error = error as? IPCClient.Error, + case IPCClient.Error.noIPCData = error { // Will happen if the extension is not enabled Log.warning("\(#function): Unable to count logs: \(error). Is the XPC service running?") } else { @@ -648,7 +648,7 @@ public struct SettingsView: View { try Log.clear(in: SharedAccess.logFolderURL) #if os(macOS) - try await store.vpnConfigurationManager.clearLogs() + try await store.clearLogs() #endif } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/macOSAlert.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/macOSAlert.swift index 60f9e40d7..53ecb483d 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/macOSAlert.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/macOSAlert.swift @@ -168,7 +168,12 @@ struct macOSAlert { // swiftlint:disable:this type_name // Code 12 case .requestSuperseded: // This will happen if the user repeatedly clicks "Enable ..." - return nil + return """ + You must enable the FirezoneNetworkExtension System Extension in System Settings to continue. Until you do, + all functionality will be disabled. + + For more information and troubleshooting, please contact your administrator. + """ // Code 13 case .authorizationRequired: diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 572583628..4257cf574 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -67,14 +67,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { guard let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)? .providerConfiguration as? [String: String], - let logFilter = providerConfiguration[VPNConfigurationManagerKeys.logFilter] + let logFilter = providerConfiguration[VPNConfigurationManager.Keys.logFilter] else { throw PacketTunnelProviderError .savedProtocolConfigurationIsInvalid("providerConfiguration.logFilter") } // Hydrate telemetry account slug - guard let accountSlug = providerConfiguration[VPNConfigurationManagerKeys.accountSlug] + guard let accountSlug = providerConfiguration[VPNConfigurationManager.Keys.accountSlug] else { // This can happen if the user deletes the VPN configuration while it's // connected. The system will try to restart us with a fresh config @@ -88,7 +88,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let internetResourceEnabled: Bool = if let internetResourceEnabledJSON = providerConfiguration[ - VPNConfigurationManagerKeys.internetResourceEnabled]?.data(using: .utf8) { + VPNConfigurationManager.Keys.internetResourceEnabled]?.data(using: .utf8) { (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false } else { false @@ -165,11 +165,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } // It would be helpful to be able to encapsulate Errors here. To do that - // we need to update TunnelMessage to encode/decode Result to and from Data. + // we need to update ProviderMessage to encode/decode Result to and from Data. override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { - guard let tunnelMessage = try? PropertyListDecoder().decode(TunnelMessage.self, from: message) else { return } + guard let providerMessage = try? PropertyListDecoder().decode(ProviderMessage.self, from: message) else { return } - switch tunnelMessage { + switch providerMessage { case .internetResourceEnabled(let value): adapter?.setInternetResourceEnabled(value) case .signOut: