diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift index 14885fa85..31fd01c78 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift @@ -4,13 +4,12 @@ // LICENSE: Apache-2.0 // -import CryptoKit import Foundation @preconcurrency import NetworkExtension // TODO: Use a more abstract IPC protocol to make this less terse -actor IPCClient { +enum IPCClient { enum Error: Swift.Error { case decodeIPCDataFailed case noIPCData @@ -28,40 +27,26 @@ actor IPCClient { } } - // IPC only makes sense if there's a valid session. Session in this case refers to the `connection` field of - // the NETunnelProviderManager instance. - nonisolated 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 - nonisolated let encoder = PropertyListEncoder() - nonisolated let decoder = PropertyListDecoder() + static let encoder = PropertyListEncoder() + static let decoder = PropertyListDecoder() // Auto-connect @MainActor - func start(configuration: Configuration) throws { + static func start(session: NETunnelProviderSession, configuration: Configuration) throws { let tunnelConfiguration = configuration.toTunnelConfiguration() let configData = try encoder.encode(tunnelConfiguration) let options: [String: NSObject] = [ "configuration": configData as NSObject ] - try session().startTunnel(options: options) + try validateSession(session).startTunnel(options: options) } // Sign in @MainActor - func start(token: String, configuration: Configuration) throws { + static func start(session: NETunnelProviderSession, token: String, configuration: Configuration) + throws + { let tunnelConfiguration = configuration.toTunnelConfiguration() let configData = try encoder.encode(tunnelConfiguration) let options: [String: NSObject] = [ @@ -69,16 +54,16 @@ actor IPCClient { "configuration": configData as NSObject, ] - try session().startTunnel(options: options) + try validateSession(session).startTunnel(options: options) } - func signOut() async throws { - try await sendMessageWithoutResponse(ProviderMessage.signOut) - try stop() + static func signOut(session: NETunnelProviderSession) async throws { + try await sendMessageWithoutResponse(session: session, message: ProviderMessage.signOut) + try stop(session: session) } - nonisolated func stop() throws { - try session().stopTunnel() + static func stop(session: NETunnelProviderSession) throws { + try validateSession(session).stopTunnel() } #if os(macOS) @@ -86,72 +71,41 @@ actor IPCClient { // Since we rely on IPC for the GUI to function, we need to send a dummy `startTunnel` that doesn't actually // start the tunnel, but causes the system to wake the extension. @MainActor - func dryStartStopCycle(configuration: Configuration) throws { + static func dryStartStopCycle(session: NETunnelProviderSession, configuration: Configuration) + throws + { let tunnelConfiguration = configuration.toTunnelConfiguration() let configData = try encoder.encode(tunnelConfiguration) let options: [String: NSObject] = [ "dryRun": true as NSObject, "configuration": configData as NSObject, ] - try session().startTunnel(options: options) + try validateSession(session).startTunnel(options: options) } #endif - func setConfiguration(_ configuration: Configuration) async throws { + static func setConfiguration(session: NETunnelProviderSession, _ configuration: Configuration) + async throws + { let tunnelConfiguration = await configuration.toTunnelConfiguration() let message = ProviderMessage.setConfiguration(tunnelConfiguration) - if sessionStatus() != .connected { + if session.status != .connected { Log.trace("Not setting configuration whilst not connected") return } - try await sendMessageWithoutResponse(message) + try await sendMessageWithoutResponse(session: session, message: message) } - func fetchResources() async throws -> ResourceList { - // Capture current hash before entering continuation - let currentHash = resourceListHash - - // Get data from the provider - continuation returns just the data - let data = 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(currentHash)) - ) { data in - continuation.resume(returning: data) - } - } catch { - continuation.resume(throwing: error) - } - } - - // Back on the actor - safe to access and mutate state directly - guard let data = data else { - // No data returned; Resources haven't changed - return resourcesListCache - } - - // Save hash to compare against - resourceListHash = Data(SHA256.hash(data: data)) - - // Decode and cache the new resource list - let decoded = try decoder.decode([Resource].self, from: data) - resourcesListCache = ResourceList.loaded(decoded) - - return resourcesListCache + static func clearLogs(session: NETunnelProviderSession) async throws { + try await sendMessageWithoutResponse(session: session, message: ProviderMessage.clearLogs) } - func clearLogs() async throws { - try await sendMessageWithoutResponse(ProviderMessage.clearLogs) - } - - func getLogFolderSize() async throws -> Int64 { + static func getLogFolderSize(session: NETunnelProviderSession) async throws -> Int64 { return try await withCheckedThrowingContinuation { continuation in do { - try session().sendProviderMessage( + try validateSession(session).sendProviderMessage( encoder.encode(ProviderMessage.getLogFolderSize) ) { data in @@ -175,13 +129,14 @@ actor IPCClient { // 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. - nonisolated func exportLogs( + static func exportLogs( + session: NETunnelProviderSession, appender: @escaping (LogChunk) -> Void, errorHandler: @escaping (Error) -> Void ) { func loop() { do { - try session().sendProviderMessage( + try validateSession(session).sendProviderMessage( encoder.encode(ProviderMessage.exportLogs) ) { data in guard let data = data @@ -192,7 +147,7 @@ actor IPCClient { } guard - let chunk = try? self.decoder.decode( + let chunk = try? decoder.decode( LogChunk.self, from: data ) else { @@ -219,40 +174,35 @@ actor IPCClient { // Subscribe to system notifications about our VPN status changing // and let our handler know about them. - nonisolated func subscribeToVPNStatusUpdates( + static func subscribeToVPNStatusUpdates( + session: NETunnelProviderSession, handler: @escaping @MainActor (NEVPNStatus) async throws -> Void ) { Task { for await notification in NotificationCenter.default.notifications( named: .NEVPNStatusDidChange) { - guard let session = notification.object as? NETunnelProviderSession + guard let notificationSession = notification.object as? NETunnelProviderSession else { return } - if session.status == .disconnected { - // Reset resource list on disconnect - await self.resetResourceList() + // Only handle notifications for our session + if notificationSession === session { + do { try await handler(notificationSession.status) } catch { Log.error(error) } } - - do { try await handler(session.status) } catch { Log.error(error) } } } } - private func resetResourceList() { - resourceListHash = Data() - resourcesListCache = ResourceList.loading - } - - nonisolated func sessionStatus() -> NEVPNStatus { + static func sessionStatus(session: NETunnelProviderSession) -> NEVPNStatus { return session.status } - nonisolated private func session(_ requiredStatuses: Set = []) throws - -> NETunnelProviderSession - { + private static func validateSession( + _ session: NETunnelProviderSession, + requiredStatuses: Set = [] + ) throws -> NETunnelProviderSession { if requiredStatuses.isEmpty || requiredStatuses.contains(session.status) { return session } @@ -260,10 +210,13 @@ actor IPCClient { throw Error.invalidStatus(session.status) } - private func sendMessageWithoutResponse(_ message: ProviderMessage) async throws { + private static func sendMessageWithoutResponse( + session: NETunnelProviderSession, + message: ProviderMessage + ) async throws { try await withCheckedThrowingContinuation { continuation in do { - try session().sendProviderMessage(encoder.encode(message)) { _ in + try validateSession(session).sendProviderMessage(encoder.encode(message)) { _ in continuation.resume() } } catch { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/LogExporter.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/LogExporter.swift index e907ceb96..868c25fa3 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/LogExporter.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/LogExporter.swift @@ -6,6 +6,7 @@ import AppleArchive import Foundation +@preconcurrency import NetworkExtension import System /// Convenience module for smoothing over the differences between exporting logs on macOS and iOS. @@ -28,7 +29,7 @@ import System static func export( to archiveURL: URL, - with ipcClient: IPCClient + session: NETunnelProviderSession ) async throws { guard let logFolderURL = SharedAccess.logFolderURL else { @@ -54,7 +55,8 @@ import System // 3. Await tunnel log export from tunnel process try await withCheckedThrowingContinuation { continuation in - ipcClient.exportLogs( + IPCClient.exportLogs( + session: session, 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 4bfcaee49..3dd2f806b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift @@ -101,7 +101,6 @@ public class VPNConfigurationManager: @unchecked Sendable { } let configuration = Configuration.shared - let ipcClient = IPCClient(session: session) if let actorName = legacyConfiguration["actorName"] { UserDefaults.standard.set(actorName, forKey: "actorName") @@ -131,7 +130,7 @@ public class VPNConfigurationManager: @unchecked Sendable { configuration.internetResourceEnabled = internetResourceEnabled == "true" } - try await ipcClient.setConfiguration(configuration) + try await IPCClient.setConfiguration(session: session, configuration) // Remove fields to prevent confusion if the user sees these in System Settings and wonders why they're stale. if let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index e81e6ff0d..9eaf4cadf 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -5,6 +5,7 @@ // import Combine +import CryptoKit import NetworkExtension import OSLog import UserNotifications @@ -24,6 +25,10 @@ public final class Store: ObservableObject { // Enacapsulate Tunnel status here to make it easier for other components to observe @Published private(set) var vpnStatus: NEVPNStatus? + // Hash for resource list optimisation + private var resourceListHash = Data() + private let decoder = PropertyListDecoder() + // User notifications @Published private(set) var decision: UNAuthorizationStatus? @@ -42,9 +47,6 @@ public final class Store: ObservableObject { private var vpnConfigurationManager: VPNConfigurationManager? private var cancellables: Set = [] - // Cached IPCClient instance - one per Store instance - private var cachedIPCClient: IPCClient? - // Track which session expired alerts have been shown to prevent duplicates private var shownAlertIds: Set @@ -70,7 +72,10 @@ public final class Store: ObservableObject { if self.vpnConfigurationManager != nil { Task { - do { try await self.ipcClient().setConfiguration(self.configuration) } catch { + do { + guard let session = try self.manager().session() else { return } + try await IPCClient.setConfiguration(session: session, self.configuration) + } catch { Log.error(error) } } @@ -114,9 +119,14 @@ public final class Store: ObservableObject { [weak self] status in try await self?.handleVPNStatusChange(newVPNStatus: status) } - try ipcClient().subscribeToVPNStatusUpdates(handler: vpnStatusChangeHandler) - let initialStatus = try ipcClient().sessionStatus() + guard let session = try manager().session() else { + throw VPNConfigurationManagerError.managerNotInitialized + } + + IPCClient.subscribeToVPNStatusUpdates(session: session, handler: vpnStatusChangeHandler) + + let initialStatus = IPCClient.sessionStatus(session: session) // Handle initial status to ensure resources start loading if already connected try await handleVPNStatusChange(newVPNStatus: initialStatus) @@ -197,36 +207,19 @@ public final class Store: ObservableObject { private func maybeAutoConnect() async throws { if configuration.connectOnStart { try await manager().enable() - try ipcClient().start(configuration: configuration) + guard let session = try manager().session() else { + throw VPNConfigurationManagerError.managerNotInitialized + } + try IPCClient.start(session: session, configuration: configuration) } } func installVPNConfiguration() async throws { // Create a new VPN configuration in system settings. self.vpnConfigurationManager = try await VPNConfigurationManager() - // Invalidate cached IPCClient since we have a new configuration - cachedIPCClient = nil - try await setupTunnelObservers() } - func ipcClient() throws -> IPCClient { - // Return cached instance if it exists - if let cachedIPCClient = cachedIPCClient { - return cachedIPCClient - } - - // Create new instance and cache it - guard let session = try manager().session() - else { - throw VPNConfigurationManagerError.managerNotInitialized - } - - let client = IPCClient(session: session) - cachedIPCClient = client - return client - } - func manager() throws -> VPNConfigurationManager { guard let vpnConfigurationManager else { @@ -241,16 +234,20 @@ public final class Store: ObservableObject { } public func stop() async throws { + guard let session = try manager().session() else { + throw VPNConfigurationManagerError.managerNotInitialized + } + #if os(macOS) // On macOS, the system removes the utun interface on stop ONLY if the VPN is in a connected state. // So we need to do a dry run start-then-stop if we're not connected, to ensure the interface is removed. if vpnStatus == .connected || vpnStatus == .connecting || vpnStatus == .reasserting { - try ipcClient().stop() + try IPCClient.stop(session: session) } else { - try ipcClient().dryStartStopCycle(configuration: configuration) + try IPCClient.dryStartStopCycle(session: session, configuration: configuration) } #else - try ipcClient().stop() + try IPCClient.stop(session: session) #endif } @@ -272,15 +269,24 @@ public final class Store: ObservableObject { UserDefaults.standard.removeObject(forKey: "shownAlertIds") // Bring the tunnel up and send it a token and configuration to start - try ipcClient().start(token: authResponse.token, configuration: configuration) + guard let session = try manager().session() else { + throw VPNConfigurationManagerError.managerNotInitialized + } + try IPCClient.start(session: session, token: authResponse.token, configuration: configuration) } func signOut() async throws { - try await ipcClient().signOut() + guard let session = try manager().session() else { + throw VPNConfigurationManagerError.managerNotInitialized + } + try await IPCClient.signOut(session: session) } func clearLogs() async throws { - try await ipcClient().clearLogs() + guard let session = try manager().session() else { + throw VPNConfigurationManagerError.managerNotInitialized + } + try await IPCClient.clearLogs(session: session) } // MARK: Private functions @@ -307,7 +313,8 @@ public final class Store: ObservableObject { self.resourceUpdateTask = Task { if !Task.isCancelled { do { - self.resourceList = try await self.ipcClient().fetchResources() + guard let session = try self.manager().session() else { return } + try await self.fetchResources(session: session) } catch let error as NSError { // https://developer.apple.com/documentation/networkextension/nevpnerror-swift.struct/code if error.domain == "NEVPNErrorDomain" && error.code == 1 { @@ -341,5 +348,49 @@ public final class Store: ObservableObject { resourcesTimer?.invalidate() resourcesTimer = nil resourceList = ResourceList.loading + resourceListHash = Data() + } + + /// Fetches resources from the tunnel provider, using hash-based optimisation. + /// + /// If the resource list hash matches what the provider has, resources are unchanged. + /// Otherwise, fetches and caches the new list. + /// + /// - Parameter session: The tunnel provider session to communicate with + /// - Throws: IPCClient.Error if IPC communication fails + private func fetchResources(session: NETunnelProviderSession) async throws { + // Capture current hash before IPC call + let currentHash = resourceListHash + + // Get data from the provider - if hash matches, provider returns nil + let data = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + do { + guard session.status == .connected else { + throw IPCClient.Error.invalidStatus(session.status) + } + + try session.sendProviderMessage( + IPCClient.encoder.encode(ProviderMessage.getResourceList(currentHash)) + ) { data in + continuation.resume(returning: data) + } + } catch { + continuation.resume(throwing: error) + } + } + + // If no data returned, resources haven't changed - no update needed + guard let data = data else { + return + } + + // Compute new hash and decode resources + let newHash = Data(SHA256.hash(data: data)) + let decoded = try decoder.decode([Resource].self, from: data) + + // Update both hash and resource list + resourceListHash = newHash + resourceList = ResourceList.loaded(decoded) } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index 615b81973..f7212d5e4 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -622,9 +622,12 @@ public struct SettingsView: View { Task { do { + guard let session = try store.manager().session() else { + throw VPNConfigurationManagerError.managerNotInitialized + } try await LogExporter.export( to: destinationURL, - with: store.ipcClient() + session: session ) window.contentViewController?.presentingViewController?.dismiss(self) @@ -707,7 +710,10 @@ public struct SettingsView: View { do { #if os(macOS) - let providerLogFolderSize = try await store.ipcClient().getLogFolderSize() + guard let session = try store.manager().session() else { + throw VPNConfigurationManagerError.managerNotInitialized + } + let providerLogFolderSize = try await IPCClient.getLogFolderSize(session: session) let totalSize = logFolderSize + providerLogFolderSize #else let totalSize = logFolderSize