From 1ceccc0da0ff81050e8dde7c0b1c7f57e958b94b Mon Sep 17 00:00:00 2001 From: Jamil Date: Sun, 11 May 2025 22:27:49 -0700 Subject: [PATCH] refactor(apple): Consolidate app configuration to UserDefaults (#9056) We are currently storing app configuration across three places: - UserDefaults (favorite resources) - VPN configuration (Settings) - Disk (firezone id) These can be consolidated to UserDefaults, which is the standard way to store app configuration like this. UserDefaults is the umbrella persistence store for regular app configuration (`plist` files which are just XML dictionaries), iCloud-synced app configuration across a user's devices, and managed app configuration (MDM). They provide a cached, thread-safe, and interprocess-supported mechanism for handling app configuration. We can also subscribe to changes on this app configuration to react to changes. Unfortunately, the System Extension ruins some of our fun because it runs as root, and is confined to a different group container, meaning we cannot share configuration directly between GUI and tunnel procs. To address this, we use the tunnel process to store all vital configuration and introduce IPC calls to set and fetch these. Commit-by-commit review recommended, but things got a little crazy towards the end when I realized that we can't share a single UserDefaults between both procs. --- .../apple/Firezone.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/xcschemes/Firezone.xcscheme | 2 +- .../FirezoneNetworkExtensionmacOS.xcscheme | 2 +- .../Firezone/Application/FirezoneApp.swift | 3 +- .../Sources/FirezoneKit/Helpers/Bundle.swift | 4 +- .../FirezoneKit/Helpers/IPCClient.swift | 114 +++++++---- .../FirezoneKit/Helpers/Telemetry.swift | 7 +- .../Managers/VPNConfigurationManager.swift | 157 ++++++--------- .../FirezoneKit/Models/Configuration.swift | 47 +++++ .../FirezoneKit/Models/FirezoneId.swift | 64 ------- .../FirezoneKit/Models/ProviderMessage.swift | 66 ++++++- .../Sources/FirezoneKit/Models/Settings.swift | 97 ---------- .../FirezoneKit/Models/WebAuthSession.swift | 5 +- .../Sources/FirezoneKit/Stores/Store.swift | 92 +++++---- .../Sources/FirezoneKit/Views/AppView.swift | 11 +- .../Sources/FirezoneKit/Views/MenuBar.swift | 19 +- .../FirezoneKit/Views/ResourceView.swift | 2 +- .../FirezoneKit/Views/SessionView.swift | 2 +- .../FirezoneKit/Views/SettingsView.swift | 180 ++++++++++++------ .../FirezoneKit/Views/iOSNavigationView.swift | 24 +-- .../FirezoneNetworkExtension/Adapter.swift | 24 +-- .../ConfigurationManager.swift | 107 +++++++++++ .../PacketTunnelProvider.swift | 152 ++++++++------- 23 files changed, 679 insertions(+), 512 deletions(-) create mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift delete mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift delete mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift create mode 100644 swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj index f74ebe2ae..1da2342bc 100644 --- a/swift/apple/Firezone.xcodeproj/project.pbxproj +++ b/swift/apple/Firezone.xcodeproj/project.pbxproj @@ -32,6 +32,8 @@ 8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; }; 8D5048042CE6B0AE009802E9 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */; }; 8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */; }; + 8DA9BFD12DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */; }; + 8DA9BFD22DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */; }; 8DC08BD72B297DB400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */; }; 8DC1699D2CFF77D1006801B5 /* dev.firezone.firezone.network-extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 05CF1CF0290B1CEE00CF4755 /* dev.firezone.firezone.network-extension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8DC169A02CFF77D1006801B5 /* dev.firezone.firezone.network-extension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 8D5047E32CE6A8F4009802E9 /* dev.firezone.firezone.network-extension.systemextension */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -108,6 +110,7 @@ 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BindResolvers.swift; sourceTree = ""; }; 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemConfigurationResolvers.swift; sourceTree = ""; }; 8DA12C322BB7DA04007D91EB /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationManager.swift; sourceTree = ""; }; 8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = usr/lib/libresolv.9.tbd; sourceTree = SDKROOT; }; 8DC08BD12B297B7B00675F46 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; 8DC333F72D2FA85200E627D5 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.2.sdk/usr/lib/libresolv.tbd; sourceTree = DEVELOPER_DIR; }; @@ -171,6 +174,7 @@ 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */, 6FE454EA2A5BFABA006549B1 /* Adapter.swift */, 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */, + 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */, 6FE455082A5D110D006549B1 /* CallbackHandler.swift */, 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */, 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */, @@ -334,7 +338,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1610; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 1630; TargetAttributes = { 05CF1CEF290B1CEE00CF4755 = { CreatedOnToolsVersion = 14.0.1; @@ -492,6 +496,7 @@ 8D41B9A52D15DD6800D16065 /* TunnelLogArchive.swift in Sources */, 05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */, 6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */, + 8DA9BFD12DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */, 6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */, 8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */, 6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */, @@ -512,6 +517,7 @@ 8D5048002CE6AA60009802E9 /* SystemConfigurationResolvers.swift in Sources */, 8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */, 8D5047FF2CE6AA54009802E9 /* Adapter.swift in Sources */, + 8DA9BFD22DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -739,6 +745,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 47R2M6779T; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -807,6 +814,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 47R2M6779T; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/swift/apple/Firezone.xcodeproj/xcshareddata/xcschemes/Firezone.xcscheme b/swift/apple/Firezone.xcodeproj/xcshareddata/xcschemes/Firezone.xcscheme index 7b21f9cd2..45810606c 100644 --- a/swift/apple/Firezone.xcodeproj/xcshareddata/xcschemes/Firezone.xcscheme +++ b/swift/apple/Firezone.xcodeproj/xcshareddata/xcschemes/Firezone.xcscheme @@ -1,6 +1,6 @@ Bool { if let receiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: receiptURL.path) { @@ -24,7 +24,7 @@ enum BundleHelper { return String(gitSha.prefix(8)) } - static var appGroupId: String { + public static var appGroupId: String { guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String else { fatalError("AppGroupIdentifier missing in app's Info.plist") diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift index 854f1b684..dbc6ae099 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift @@ -8,6 +8,9 @@ import CryptoKit import Foundation import NetworkExtension +// TODO: Use a more abstract IPC protocol to make this less terse +// TODO: Consider making this an actor to guarantee strict ordering + class IPCClient { enum Error: Swift.Error { case invalidNotification @@ -41,17 +44,17 @@ class IPCClient { // return them to callers when they haven't changed. var resourcesListCache: ResourceList = ResourceList.loading + // Cache the configuration on this side of the IPC barrier so we can return it to callers if it hasn't changed. + private var configurationHash = Data() + private var configurationCache: Configuration? + init(session: NETunnelProviderSession) { self.session = session } // Encoder used to send messages to the tunnel - let encoder = { - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - - return encoder - }() + let encoder = PropertyListEncoder() + let decoder = PropertyListDecoder() func start(token: String? = nil) throws { var options: [String: NSObject] = [:] @@ -61,27 +64,69 @@ class IPCClient { 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 signOut() async throws { + try await sendMessageWithoutResponse(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 getConfiguration() async throws -> Configuration? { + return try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage( + encoder.encode(ProviderMessage.getConfiguration(configurationHash)) + ) { data in + guard let data = data + else { + // Configuration hasn't changed + continuation.resume(returning: self.configurationCache) + return + } + + // Compute new hash + self.configurationHash = Data(SHA256.hash(data: data)) + + do { + let decoded = try self.decoder.decode(Configuration.self, from: data) + self.configurationCache = decoded + continuation.resume(returning: decoded) + } catch { + continuation.resume(throwing: error) + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + + func setAuthURL(_ authURL: URL) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setAuthURL(authURL)) + } + + func setApiURL(_ apiURL: URL) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setApiURL(apiURL)) + } + + func setLogFilter(_ logFilter: String) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setLogFilter(logFilter)) + } + + func setActorName(_ actorName: String) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setActorName(actorName)) + } + + func setAccountSlug(_ accountSlug: String) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setAccountSlug(accountSlug)) + } + + func setInternetResourceEnabled(_ enabled: Bool) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setInternetResourceEnabled(enabled)) } func fetchResources() async throws -> ResourceList { @@ -102,11 +147,11 @@ class IPCClient { // Save hash to compare against self.resourceListHash = Data(SHA256.hash(data: data)) - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase + let jsonDecoder = JSONDecoder() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase do { - let decoded = try decoder.decode([Resource].self, from: data) + let decoded = try jsonDecoder.decode([Resource].self, from: data) self.resourcesListCache = ResourceList.loaded(decoded) continuation.resume(returning: self.resourcesListCache) @@ -121,15 +166,7 @@ class IPCClient { } 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) - } - } + try await sendMessageWithoutResponse(ProviderMessage.clearLogs) } func getLogFolderSize() async throws -> Int64 { @@ -164,8 +201,6 @@ class IPCClient { appender: @escaping (LogChunk) -> Void, errorHandler: @escaping (Error) -> Void ) { - let decoder = PropertyListDecoder() - func loop() { do { try session().sendProviderMessage( @@ -178,7 +213,7 @@ class IPCClient { return } - guard let chunk = try? decoder.decode( + guard let chunk = try? self.decoder.decode( LogChunk.self, from: data ) else { @@ -260,4 +295,17 @@ class IPCClient { throw Error.invalidStatus(session.status) } + + private func sendMessageWithoutResponse(_ message: ProviderMessage) async throws { + try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage(encoder.encode(message)) { _ in + continuation.resume() + } + } catch { + Log.error(error) + continuation.resume(throwing: error) + } + } + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift index 22be878e4..6b030fb1a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Telemetry.swift @@ -41,12 +41,13 @@ public enum Telemetry { } } - public static func setEnvironmentOrClose(_ apiURLString: String) { + public static func setEnvironmentOrClose(_ apiURL: URL) { var environment: String? + let str = apiURL.absoluteString - if apiURLString.starts(with: "wss://api.firezone.dev") { + if str.starts(with: "wss://api.firezone.dev") { environment = "production" - } else if apiURLString.starts(with: "wss://api.firez.one") { + } else if str.starts(with: "wss://api.firez.one") { environment = "staging" } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift index 70c50f32c..575e9ebba 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift @@ -12,28 +12,16 @@ import NetworkExtension enum VPNConfigurationManagerError: Error { case managerNotInitialized - case savedProtocolConfigurationIsInvalid var localizedDescription: String { switch self { case .managerNotInitialized: return "NETunnelProviderManager is not yet initialized. Race condition?" - case .savedProtocolConfigurationIsInvalid: - return "Saved protocol configuration is invalid. Check types?" } } } public class VPNConfigurationManager { - 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 let manager: NETunnelProviderManager @@ -44,11 +32,10 @@ public class VPNConfigurationManager { init() async throws { let protocolConfiguration = NETunnelProviderProtocol() let manager = NETunnelProviderManager() - let settings = Settings.defaultValue - protocolConfiguration.providerConfiguration = settings.toProviderConfiguration() + protocolConfiguration.providerConfiguration = nil protocolConfiguration.providerBundleIdentifier = VPNConfigurationManager.bundleIdentifier - protocolConfiguration.serverAddress = settings.apiURL + protocolConfiguration.serverAddress = "Firezone" // can be any non-empty string manager.localizedDescription = VPNConfigurationManager.bundleDescription manager.protocolConfiguration = protocolConfiguration @@ -74,76 +61,6 @@ public class VPNConfigurationManager { return nil } - func actorName() throws -> String? { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid - } - - 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 - - try await enableConfiguration() - } - - func save(settings: Settings) async throws { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid - } - - var newProviderConfiguration = settings.toProviderConfiguration() - - // Don't clobber existing actorName - newProviderConfiguration[Keys.actorName] = providerConfiguration[Keys.actorName] - - protocolConfiguration.providerConfiguration = newProviderConfiguration - protocolConfiguration.serverAddress = settings.apiURL - manager.protocolConfiguration = protocolConfiguration - - try await enableConfiguration() - - // Reconfigure our Telemetry environment in case it changed - Telemetry.setEnvironmentOrClose(settings.apiURL) - } - // If another VPN is activated on the system, ours becomes disabled. This is provided so that we may call it before // each start attempt in order to reactivate our configuration. func enableConfiguration() async throws { @@ -152,17 +69,67 @@ public class VPNConfigurationManager { try await manager.loadFromPreferences() } - func settings() throws -> Settings { - guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String] - else { - throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid - } - - return Settings.fromProviderConfiguration(providerConfiguration) - } - func session() -> NETunnelProviderSession? { return manager.connection as? NETunnelProviderSession } + + // Firezone 1.4.14 and below stored some app configuration in the VPN provider configuration fields. This has since + // been moved to a dedicated UserDefaults-backed persistent store. + func maybeMigrateConfiguration() async throws { + guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol, + let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String], + let session = session() + else { return } + + let ipcClient = IPCClient(session: session) + + var migrated = false + + if let apiURLString = providerConfiguration["apiURL"], + let apiURL = URL(string: apiURLString), + apiURL.host != nil, + ["wss", "ws"].contains(apiURL.scheme) { + try await ipcClient.setApiURL(apiURL) + migrated = true + } + + if let authURLString = providerConfiguration["authBaseURL"], + let authURL = URL(string: authURLString), + authURL.host != nil, + ["https", "http"].contains(authURL.scheme) { + try await ipcClient.setAuthURL(authURL) + migrated = true + } + + if let actorName = providerConfiguration["actorName"] { + try await ipcClient.setActorName(actorName) + migrated = true + } + + if let accountSlug = providerConfiguration["accountSlug"] { + try await ipcClient.setAccountSlug(accountSlug) + migrated = true + } + + if let logFilter = providerConfiguration["logFilter"], + !logFilter.isEmpty { + try await ipcClient.setLogFilter(logFilter) + migrated = true + } + + if let internetResourceEnabled = providerConfiguration["internetResourceEnabled"], + ["false", "true"].contains(internetResourceEnabled) { + try await ipcClient.setInternetResourceEnabled(internetResourceEnabled == "true") + migrated = true + } + + if !migrated { return } + + // Remove fields to prevent confusion if the user sees these in System Settings and wonders why they're stale. + protocolConfiguration.providerConfiguration = nil + protocolConfiguration.serverAddress = "Firezone" + manager.protocolConfiguration = protocolConfiguration + try await manager.saveToPreferences() + try await manager.loadFromPreferences() + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift new file mode 100644 index 000000000..2be5eabb8 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift @@ -0,0 +1,47 @@ +// +// Configuration.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +import Foundation + +public struct Configuration: Codable { + public enum Keys { + public static let authURL = "dev.firezone.configuration.authURL" + public static let apiURL = "dev.firezone.configuration.apiURL" + public static let logFilter = "dev.firezone.configuration.logFilter" + public static let actorName = "dev.firezone.configuration.actorName" + public static let accountSlug = "dev.firezone.configuration.accountSlug" + public static let internetResourceEnabled = "dev.firezone.configuration.internetResourceEnabled" + public static let firezoneId = "dev.firezone.configuration.firezoneId" + } + + var actorName: String? + var authURL: URL? + var apiURL: URL? + var logFilter: String? + var accountSlug: String? + var firezoneId: String? + var internetResourceEnabled: Bool? + + public init(from dict: [String: Any?]) { + self.actorName = dict[Keys.actorName] as? String + self.authURL = dict[Keys.authURL] as? URL + self.apiURL = dict[Keys.apiURL] as? URL + self.logFilter = dict[Keys.logFilter] as? String + self.accountSlug = dict[Keys.accountSlug] as? String + self.firezoneId = dict[Keys.firezoneId] as? String + self.internetResourceEnabled = dict[Keys.internetResourceEnabled] as? Bool + } + +#if DEBUG + public static let defaultAuthURL = URL(string: "https://app.firez.one")! + public static let defaultApiURL = URL(string: "wss://api.firez.one")! + public static let defaultLogFilter = "debug" +#else + public static let defaultAuthURL = URL(string: "https://app.firezone.dev")! + public static let defaultApiURL = URL(string: "wss://api.firezone.dev")! + public static let defaultLogFilter = "info" +#endif +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift deleted file mode 100644 index ba00e6467..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/FirezoneId.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// FirezoneId.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// -// Convenience wrapper for working with our firezone-id file stored by the -// tunnel process. - -import Foundation - -/// Prior to 1.4.0, our firezone-id was saved in a file accessible to both the -/// app and tunnel process. Starting with 1.4.0, -/// the macOS client uses a system extension, which makes sharing folders with -/// the app cumbersome, so we move to persisting the firezone-id only from the -/// tunnel process since that is the only place it's used. -/// -/// Can be refactored to remove the Version enum all clients >= 1.4.0 -public struct FirezoneId { - public enum Version { - case pre140 - case post140 - } - - public static func save(_ id: String) { - guard let fileURL = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId)? - .appendingPathComponent("firezone-id") - else { - // Nothing we can do about disk errors - return - } - - try? id.write( - to: fileURL, - atomically: true, - encoding: .utf8 - ) - } - - public static func load(_ version: Version) -> String? { - let appGroupId = switch version { - case .post140: - BundleHelper.appGroupId - case .pre140: -#if os(macOS) - "47R2M6779T.group.dev.firezone.firezone" -#elseif os(iOS) - "group.dev.firezone.firezone" -#endif - } - - guard let containerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupId), - let id = - try? String( - contentsOf: containerURL.appendingPathComponent("firezone-id")) - else { - return nil - } - - return id - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift index 4a6cd16cb..7bfee7c74 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift @@ -7,10 +7,19 @@ import Foundation +// TODO: Can we simplify this / abstract it? +// swiftlint:disable cyclomatic_complexity + public enum ProviderMessage: Codable { case getResourceList(Data) + case getConfiguration(Data) case signOut - case internetResourceEnabled(Bool) + case setAuthURL(URL) + case setApiURL(URL) + case setLogFilter(String) + case setActorName(String) + case setAccountSlug(String) + case setInternetResourceEnabled(Bool) case clearLogs case getLogFolderSize case exportLogs @@ -23,8 +32,14 @@ public enum ProviderMessage: Codable { enum MessageType: String, Codable { case getResourceList + case getConfiguration case signOut - case internetResourceEnabled + case setAuthURL + case setApiURL + case setLogFilter + case setActorName + case setAccountSlug + case setInternetResourceEnabled case clearLogs case getLogFolderSize case exportLogs @@ -35,12 +50,30 @@ public enum ProviderMessage: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(MessageType.self, forKey: .type) switch type { - case .internetResourceEnabled: + case .setAuthURL: + let value = try container.decode(URL.self, forKey: .value) + self = .setAuthURL(value) + case .setApiURL: + let value = try container.decode(URL.self, forKey: .value) + self = .setApiURL(value) + case .setLogFilter: + let value = try container.decode(String.self, forKey: .value) + self = .setLogFilter(value) + case .setActorName: + let value = try container.decode(String.self, forKey: .value) + self = .setActorName(value) + case .setAccountSlug: + let value = try container.decode(String.self, forKey: .value) + self = .setAccountSlug(value) + case .setInternetResourceEnabled: let value = try container.decode(Bool.self, forKey: .value) - self = .internetResourceEnabled(value) + self = .setInternetResourceEnabled(value) case .getResourceList: let value = try container.decode(Data.self, forKey: .value) self = .getResourceList(value) + case .getConfiguration: + let value = try container.decode(Data.self, forKey: .value) + self = .getConfiguration(value) case .signOut: self = .signOut case .clearLogs: @@ -53,15 +86,34 @@ public enum ProviderMessage: Codable { 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) + case .setAuthURL(let value): + try container.encode(MessageType.setAuthURL, forKey: .type) + try container.encode(value, forKey: .value) + case .setApiURL(let value): + try container.encode(MessageType.setApiURL, forKey: .type) + try container.encode(value, forKey: .value) + case .setLogFilter(let value): + try container.encode(MessageType.setLogFilter, forKey: .type) + try container.encode(value, forKey: .value) + case .setActorName(let value): + try container.encode(MessageType.setActorName, forKey: .type) + try container.encode(value, forKey: .value) + case .setAccountSlug(let value): + try container.encode(MessageType.setAccountSlug, forKey: .type) + try container.encode(value, forKey: .value) + case .setInternetResourceEnabled(let value): + try container.encode(MessageType.setInternetResourceEnabled, 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 .getConfiguration(let value): + try container.encode(MessageType.getConfiguration, forKey: .type) + try container.encode(value, forKey: .value) case .signOut: try container.encode(MessageType.signOut, forKey: .type) case .clearLogs: @@ -75,3 +127,5 @@ public enum ProviderMessage: Codable { } } } + +// swiftlint:enable cyclomatic_complexity diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift deleted file mode 100644 index ce9e61b42..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Settings.swift -// (c) 2024 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import Foundation - -struct Settings: Equatable { - var authBaseURL: String - var apiURL: String - var logFilter: String - var internetResourceEnabled: Bool? - - var isValid: Bool { - let authBaseURL = URL(string: authBaseURL) - let apiURL = URL(string: apiURL) - // Technically strings like "foo" are valid URLs, but their host component - // would be nil which crashes the ASWebAuthenticationSession view when - // signing in. We should also validate the scheme, otherwise ftp:// - // could be used for example which tries to open the Finder when signing - // in. 🙃 - return authBaseURL?.host != nil - && apiURL?.host != nil - && ["http", "https"].contains(authBaseURL?.scheme) - && ["ws", "wss"].contains(apiURL?.scheme) - && !logFilter.isEmpty - } - - // Convert provider configuration (which may have empty fields if it was tampered with) to Settings - static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings { - if let providerConfiguration = providerConfiguration as? [String: String] { - return Settings( - authBaseURL: providerConfiguration[VPNConfigurationManager.Keys.authBaseURL] - ?? Settings.defaultValue.authBaseURL, - apiURL: providerConfiguration[VPNConfigurationManager.Keys.apiURL] - ?? Settings.defaultValue.apiURL, - logFilter: providerConfiguration[VPNConfigurationManager.Keys.logFilter] - ?? Settings.defaultValue.logFilter, - internetResourceEnabled: getInternetResourceEnabled( - internetResourceEnabled: providerConfiguration[VPNConfigurationManager.Keys.internetResourceEnabled]) - ) - } else { - return Settings.defaultValue - } - } - - static private func getInternetResourceEnabled(internetResourceEnabled: String?) -> Bool? { - guard let internetResourceEnabled = internetResourceEnabled, - let jsonData = internetResourceEnabled.data(using: .utf8) - else { return nil } - - return try? JSONDecoder().decode(Bool?.self, from: jsonData) - } - - // Used for initializing a new providerConfiguration from Settings - func toProviderConfiguration() -> [String: String] { - guard let data = try? JSONEncoder().encode(internetResourceEnabled), - let string = String(data: data, encoding: .utf8) - else { - fatalError("internetResourceEnabled should be encodable") - } - - return [ - VPNConfigurationManager.Keys.authBaseURL: authBaseURL, - VPNConfigurationManager.Keys.apiURL: apiURL, - VPNConfigurationManager.Keys.logFilter: logFilter, - VPNConfigurationManager.Keys.internetResourceEnabled: string - ] - } - - static let defaultValue: Settings = { - // Note: To see what the connlibLogFilterString values mean, see: - // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html - #if DEBUG - Settings( - authBaseURL: "https://app.firez.one", - apiURL: "wss://api.firez.one", - logFilter: "debug", - internetResourceEnabled: nil - ) - #else - Settings( - authBaseURL: "https://app.firezone.dev", - apiURL: "wss://api.firezone.dev", - logFilter: "info", - internetResourceEnabled: nil - ) - #endif - }() -} - -extension Settings: CustomStringConvertible { - var description: String { - "(\(authBaseURL), \(apiURL), \(logFilter)" - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift index 20cfb5648..93dd709ce 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift @@ -16,8 +16,9 @@ struct WebAuthSession { static let anchor = PresentationAnchor() static func signIn(store: Store) async throws { - guard let authURL = store.authURL(), - let authClient = try? AuthClient(authURL: authURL), + let authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL + + guard let authClient = try? AuthClient(authURL: authURL), let url = try? authClient.build() else { // Should never get here because we perform URL validation on input, but handle this just in case diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 7cdeb056c..695daec7c 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -17,24 +17,23 @@ import AppKit public final class Store: ObservableObject { @Published private(set) var favorites = Favorites() @Published private(set) var resourceList: ResourceList = .loading - @Published private(set) var actorName: String? - // Make our tunnel configuration convenient for SettingsView to consume - @Published private(set) var settings = Settings.defaultValue + // UserDefaults-backed app configuration that will publish updates to SwiftUI components + @Published private(set) var configuration: Configuration? - // Enacapsulate Tunnel status here to make it easier for other components - // to observe + // Enacapsulate Tunnel status here to make it easier for other components to observe @Published private(set) var status: NEVPNStatus? + // User notifications @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 + var firezoneId: String? + let sessionNotification = SessionNotification() private var resourcesTimer: Timer? @@ -78,11 +77,13 @@ public final class Store: ObservableObject { do { // Try to load existing configuration if let manager = try await VPNConfigurationManager.load() { + try await manager.maybeMigrateConfiguration() self.vpnConfigurationManager = manager - self.settings = try manager.settings() try await setupTunnelObservers() try await manager.enableConfiguration() try ipcClient().start() + self.configuration = try await ipcClient().getConfiguration() + Telemetry.firezoneId = configuration?.firezoneId } else { status = .invalid } @@ -105,13 +106,6 @@ public final class Store: ObservableObject { status = newStatus if status == .connected { - // Load saved actorName - actorName = try? manager().actorName() - - // Load saved internet resource status - internetResourceEnabled = try? manager().internetResourceEnabled() - - // Load Resources beginUpdatingResources() } else { endUpdatingResources() @@ -179,6 +173,9 @@ public final class Store: ObservableObject { // Create a new VPN configuration in system settings. self.vpnConfigurationManager = try await VPNConfigurationManager() + self.configuration = try await ipcClient().getConfiguration() + Telemetry.firezoneId = configuration?.firezoneId + try await setupTunnelObservers() } @@ -204,45 +201,74 @@ public final class Store: ObservableObject { self.decision = try await sessionNotification.askUserForNotificationPermissions() } - func authURL() -> URL? { - return URL(string: settings.authBaseURL) - } - func stop() throws { try ipcClient().stop() } func signIn(authResponse: AuthResponse) async throws { // Save actorName - self.actorName = authResponse.actorName + try await setActorName(authResponse.actorName) + try await setAccountSlug(authResponse.accountSlug) - try await manager().save(authResponse: authResponse) + try await manager().enableConfiguration() // Bring the tunnel up and send it a token to start try ipcClient().start(token: authResponse.token) } - func signOut() throws { - try ipcClient().signOut() + func signOut() async throws { + try await 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) + let enabled = configuration?.internetResourceEnabled == true + try await setInternetResourceEnabled(!enabled) } + // MARK: App configuration setters + + func setActorName(_ actorName: String) async throws { + try await ipcClient().setActorName(actorName) + self.configuration?.actorName = actorName + } + + func setAccountSlug(_ accountSlug: String) async throws { + try await ipcClient().setAccountSlug(accountSlug) + + // Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure. + Telemetry.accountSlug = accountSlug + } + + func setAuthURL(_ authURL: URL) async throws { + try await ipcClient().setAuthURL(authURL) + self.configuration?.authURL = authURL + } + + func setApiURL(_ apiURL: URL) async throws { + try await ipcClient().setApiURL(apiURL) + + // Reconfigure our Telemetry environment in case it changed + Telemetry.setEnvironmentOrClose(apiURL) + + self.configuration?.apiURL = apiURL + } + + func setLogFilter(_ logFilter: String) async throws { + try await ipcClient().setLogFilter(logFilter) + self.configuration?.logFilter = logFilter + } + + func setInternetResourceEnabled(_ enabled: Bool) async throws { + try await ipcClient().setInternetResourceEnabled(enabled) + self.configuration?.internetResourceEnabled = enabled + } + + // MARK: Private functions + private func start(token: String? = nil) throws { try ipcClient().start(token: token) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index 8ee6a120a..ce61a3b11 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -37,8 +37,8 @@ public struct AppView: View { WindowDefinition.main.openWindow() } - // Close window upon launch for day-to-day use - if status != .invalid && systemExtensionStatus == .installed && FirezoneId.load(.pre140) != nil { + // Close window for day to day use + if status != .invalid && systemExtensionStatus == .installed && launchedBefore() { WindowDefinition.main.window()?.close() } }) @@ -75,6 +75,13 @@ public struct AppView: View { } } } + + private static func launchedBefore() -> Bool { + let bool = UserDefaults.standard.bool(forKey: "launchedBefore") + UserDefaults.standard.set(true, forKey: "launchedBefore") + + return bool + } #endif public init() {} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 170417a8f..3039553d6 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -285,7 +285,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownFavorites = newFavorites - wasInternetResourceEnabled = store.internetResourceEnabled + wasInternetResourceEnabled = store.configuration?.internetResourceEnabled } func populateOtherResourcesMenu(_ newOthers: [Resource]) { @@ -313,7 +313,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownOthers = newOthers - wasInternetResourceEnabled = store.internetResourceEnabled + wasInternetResourceEnabled = store.configuration?.internetResourceEnabled } func updateStatusItemIcon() { @@ -350,7 +350,7 @@ public final class MenuBar: NSObject, ObservableObject { signOutMenuItem.isHidden = true settingsMenuItem.target = self case .connected, .reasserting, .connecting: - let title = "Signed in as \(store.actorName ?? "Unknown User")" + let title = "Signed in as \(store.configuration?.actorName ?? "Unknown User")" signInMenuItem.title = title signInMenuItem.target = nil signOutMenuItem.isHidden = false @@ -467,7 +467,7 @@ public final class MenuBar: NSObject, ObservableObject { return false } - return wasInternetResourceEnabled != store.internetResourceEnabled + return wasInternetResourceEnabled != store.configuration?.internetResourceEnabled } func refreshUpdateItem() { @@ -503,7 +503,7 @@ public final class MenuBar: NSObject, ObservableObject { } func internetResourceTitle(resource: Resource) -> String { - let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled + let status = store.configuration?.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 == true ? "Disable this resource" : "Enable this resource" + store.configuration?.internetResourceEnabled == true ? "Disable this resource" : "Enable this resource" } // TODO: Refactor this when refactoring for macOS 13 @@ -689,7 +689,7 @@ public final class MenuBar: NSObject, ObservableObject { } @objc func signOutButtonTapped() { - do { try store.signOut() } catch { Log.error(error) } + Task { do { try await store.signOut() } catch { Log.error(error) } } } @objc func grantPermissionMenuItemTapped() { @@ -723,10 +723,9 @@ public final class MenuBar: NSObject, ObservableObject { } @objc func adminPortalButtonTapped() { - guard let url = URL(string: store.settings.authBaseURL) - else { return } + let authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL - Task { await NSWorkspace.shared.openAsync(url) } + Task { await NSWorkspace.shared.openAsync(authURL) } } @objc func updateAvailableButtonTapped() { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift index 6e41b3e50..443878aae 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 == true { + if store.configuration?.internetResourceEnabled == true { "Disable this resource" } else { "Enable this resource" diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index 5704404a2..c91335c93 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 == true ? StatusSymbol.enabled : StatusSymbol.disabled + let status = store.configuration?.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 9ea65c8d2..775ffff34 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -64,12 +64,101 @@ extension FileManager { } } -// TODO: Refactor body length +@MainActor +class SettingsViewModel: ObservableObject { + private let store: Store + private var cancellables = Set() + + @Published var authURLString: String + @Published var apiURLString: String + @Published var logFilterString: String + @Published var areSettingsDefault = true + @Published var areSettingsValid = true + @Published var areSettingsSaved = true + + init(store: Store) { + self.store = store + + self.authURLString = store.configuration?.authURL?.absoluteString ?? Configuration.defaultAuthURL.absoluteString + self.apiURLString = store.configuration?.apiURL?.absoluteString ?? Configuration.defaultApiURL.absoluteString + self.logFilterString = store.configuration?.logFilter ?? Configuration.defaultLogFilter + + updateDerivedState() + + Publishers.CombineLatest3($authURLString, $apiURLString, $logFilterString) + .receive(on: RunLoop.main) + .sink { [weak self] (_, _, _) in + guard let self = self else { return } + + self.updateDerivedState() + } + .store(in: &cancellables) + } + + private func updateDerivedState() { + self.areSettingsSaved = (self.authURLString == store.configuration?.authURL?.absoluteString && + self.apiURLString == store.configuration?.apiURL?.absoluteString && + self.logFilterString == store.configuration?.logFilter) + + if let apiURL = URL(string: apiURLString), + let authURL = URL(string: authURLString), + authURL.host != nil, + apiURL.host != nil, + ["https", "http"].contains(authURL.scheme), + ["wss", "ws"].contains(apiURL.scheme), + !logFilterString.isEmpty { + + self.areSettingsValid = true + + } else { + + self.areSettingsValid = false + + } + + self.areSettingsDefault = (self.authURLString == Configuration.defaultAuthURL.absoluteString && + self.apiURLString == Configuration.defaultApiURL.absoluteString && + self.logFilterString == Configuration.defaultLogFilter) + } + + func applySettingsToStore() throws { + guard let authURL = URL(string: authURLString), + let apiURL = URL(string: apiURLString) + else { + Log.warning("Unexpectedly invalid settings") + + return + } + + Task { + try await store.setApiURL(apiURL) + try await store.setLogFilter(logFilterString) + try await store.setAuthURL(authURL) + + updateDerivedState() + } + } + + func revertToDefaultSettings() { + self.authURLString = Configuration.defaultAuthURL.absoluteString + self.apiURLString = Configuration.defaultApiURL.absoluteString + self.logFilterString = Configuration.defaultLogFilter + } + + func reloadSettingsFromStore() { + self.authURLString = store.configuration?.authURL?.absoluteString ?? Configuration.defaultAuthURL.absoluteString + self.apiURLString = store.configuration?.apiURL?.absoluteString ?? Configuration.defaultApiURL.absoluteString + self.logFilterString = store.configuration?.logFilter ?? Configuration.defaultLogFilter + } +} + +// TODO: Move business logic to ViewModel to remove dependency on Store and fix body length // swiftlint:disable:next type_body_length public struct SettingsView: View { - @EnvironmentObject var store: Store + @StateObject private var viewModel: SettingsViewModel @Environment(\.dismiss) var dismiss - @State var settings = Settings.defaultValue + + private let store: Store enum ConfirmationAlertContinueAction: Int { case none @@ -81,9 +170,9 @@ public struct SettingsView: View { case .none: break case .saveSettings: - view.saveSettings() + Task { do { try await view.saveSettings() } catch { Log.error(error) } } case .saveAllSettingsAndDismiss: - view.saveAllSettingsAndDismiss() + Task { do { try await view.saveAllSettingsAndDismiss() } catch { Log.error(error) } } } } } @@ -117,7 +206,10 @@ public struct SettingsView: View { ) } - public init() {} + public init(store: Store) { + self.store = store + _viewModel = StateObject(wrappedValue: SettingsViewModel(store: store)) + } public var body: some View { #if os(iOS) @@ -128,7 +220,7 @@ public struct SettingsView: View { Image(systemName: "slider.horizontal.3") Text("Advanced") } - .badge(settings.isValid ? nil : "!") + .badge(viewModel.areSettingsValid ? nil : "!") logsTab .tabItem { Image(systemName: "doc.text") @@ -147,7 +239,7 @@ public struct SettingsView: View { } } .disabled( - (settings == store.settings || !settings.isValid) + (viewModel.areSettingsSaved || !viewModel.areSettingsValid) ) } ToolbarItem(placement: .navigationBarLeading) { @@ -175,9 +267,6 @@ public struct SettingsView: View { Text("Changing settings will sign you out and disconnect you from resources") } ) - .onAppear { - settings = store.settings - } #elseif os(macOS) VStack { @@ -209,9 +298,6 @@ public struct SettingsView: View { Text("Changing settings will sign you out and disconnect you from resources") } ) - .onAppear { - settings = store.settings - } .onDisappear(perform: { self.reloadSettings() }) #else #error("Unsupported platform") @@ -227,28 +313,19 @@ public struct SettingsView: View { Form { TextField( "Auth Base URL:", - text: Binding( - get: { settings.authBaseURL }, - set: { settings.authBaseURL = $0 } - ), + text: $viewModel.authURLString, prompt: Text(PlaceholderText.authBaseURL) ) TextField( "API URL:", - text: Binding( - get: { settings.apiURL }, - set: { settings.apiURL = $0 } - ), + text: $viewModel.apiURLString, prompt: Text(PlaceholderText.apiURL) ) TextField( "Log Filter:", - text: Binding( - get: { settings.logFilter }, - set: { settings.logFilter = $0 } - ), + text: $viewModel.logFilterString, prompt: Text(PlaceholderText.logFilter) ) @@ -268,16 +345,15 @@ public struct SettingsView: View { } } ) - .disabled(settings == store.settings || !store.settings.isValid) + .disabled(viewModel.areSettingsSaved || !viewModel.areSettingsValid) Button( "Reset to Defaults", action: { - settings = Settings.defaultValue - store.favorites.reset() + viewModel.revertToDefaultSettings() } ) - .disabled(store.favorites.isEmpty() && settings == Settings.defaultValue) + .disabled(viewModel.areSettingsDefault) } .padding(.top, 5) } @@ -303,10 +379,7 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.authBaseURL, - text: Binding( - get: { settings.authBaseURL }, - set: { settings.authBaseURL = $0 } - ) + text: $viewModel.authURLString ) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -318,10 +391,7 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.apiURL, - text: Binding( - get: { settings.apiURL }, - set: { settings.apiURL = $0 } - ) + text: $viewModel.apiURLString ) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -333,10 +403,7 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.logFilter, - text: Binding( - get: { settings.logFilter }, - set: { settings.logFilter = $0 } - ) + text: $viewModel.logFilterString ) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -347,11 +414,10 @@ public struct SettingsView: View { Button( "Reset to Defaults", action: { - settings = Settings.defaultValue - store.favorites.reset() + viewModel.revertToDefaultSettings() } ) - .disabled(store.favorites.isEmpty() && settings == Settings.defaultValue) + .disabled(viewModel.areSettingsDefault) Spacer() } }, @@ -477,13 +543,13 @@ public struct SettingsView: View { #endif } - func saveAllSettingsAndDismiss() { - saveSettings() + func saveAllSettingsAndDismiss() async throws { + try await saveSettings() dismiss() } func reloadSettings() { - settings = store.settings + viewModel.reloadSettingsFromStore() dismiss() } @@ -577,18 +643,16 @@ public struct SettingsView: View { } } - func saveSettings() { - Task { - do { - if [.connected, .connecting, .reasserting].contains(store.status) { - try self.store.signOut() - } - - try await store.saveSettings(settings) - } catch { - Log.error(error) + func saveSettings() async throws { + do { + if [.connected, .connecting, .reasserting].contains(store.status) { + try await self.store.signOut() } + } catch { + Log.error(error) } + + try viewModel.applySettingsToStore() } // Calculates the total size of our logs by summing the size of the diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift index cd380ec6b..d20353c6a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift @@ -40,7 +40,7 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n ) } .sheet(isPresented: $isSettingsPresented) { - SettingsView() + SettingsView(store: store) } .navigationViewStyle(StackNavigationViewStyle()) } @@ -60,7 +60,7 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n private var authMenu: some View { Menu { if store.status == .connected { - Text("Signed in as \(store.actorName ?? "Unknown user")") + Text("Signed in as \(store.configuration?.actorName ?? "Unknown user")") Button( action: { signOutButtonTapped() @@ -120,17 +120,19 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n } func signOutButtonTapped() { - do { - try store.signOut() - } catch { - Log.error(error) + Task { + do { + try await store.signOut() + } catch { + Log.error(error) - self.errorHandler.handle( - ErrorAlert( - title: "Error signing out", - error: error + self.errorHandler.handle( + ErrorAlert( + title: "Error signing out", + error: error + ) ) - ) + } } } } diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 1a0e8715f..db8afd80c 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -164,7 +164,7 @@ class Adapter { } /// Currently disabled resources - private var internetResourceEnabled: Bool = false + private var internetResourceEnabled: Bool /// Cache of internet resource private var internetResource: Resource? @@ -173,14 +173,14 @@ class Adapter { private var resourceListJSON: String? /// Starting parameters - private let apiURL: String + private let apiURL: URL private let token: Token private let id: String private let logFilter: String private let connlibLogFolderPath: String init( - apiURL: String, + apiURL: URL, token: Token, id: String, logFilter: String, @@ -207,7 +207,7 @@ class Adapter { } /// Start the tunnel. - public func start() throws { + func start() throws { Log.log("Adapter.start") guard session == nil else { @@ -223,7 +223,7 @@ class Adapter { // Grab a session pointer session = try WrappedSession.connect( - apiURL, + "\(apiURL)", "\(token)", "\(id)", "\(Telemetry.accountSlug!)", @@ -255,7 +255,7 @@ class Adapter { /// /// This can happen before the tunnel is in the tunnelReady state, such as if the portal /// is slow to send the init. - public func stop() { + func stop() { Log.log("Adapter.stop") // Assigning `nil` will invoke `Drop` on the Rust side @@ -267,7 +267,7 @@ class Adapter { /// Get the current set of resources in the completionHandler, only returning /// them if the resource list has changed. - public func getResourcesIfVersionDifferentFrom( + func getResourcesIfVersionDifferentFrom( hash: Data, completionHandler: @escaping (String?) -> Void ) { // This is async to avoid blocking the main UI thread @@ -290,7 +290,7 @@ class Adapter { return (try? decoder.decode([Resource].self, from: resourceList.data(using: .utf8)!)) ?? [] } - public func setInternetResourceEnabled(_ enabled: Bool) { + func setInternetResourceEnabled(_ enabled: Bool) { workQueue.async { [weak self] in guard let self = self else { return } @@ -299,7 +299,7 @@ class Adapter { } } - public func resourcesUpdated() { + func resourcesUpdated() { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase @@ -356,7 +356,7 @@ extension Adapter { extension Adapter: CallbackHandlerDelegate { // swiftlint:disable:next function_parameter_count - public func onSetInterfaceConfig( + func onSetInterfaceConfig( tunnelAddressIPv4: String, tunnelAddressIPv6: String, searchDomain: String?, @@ -396,7 +396,7 @@ extension Adapter: CallbackHandlerDelegate { } } - public func onUpdateResources(resourceList: String) { + func onUpdateResources(resourceList: String) { // This is a queued callback to ensure ordering workQueue.async { [weak self] in guard let self = self else { return } @@ -410,7 +410,7 @@ extension Adapter: CallbackHandlerDelegate { } } - public func onDisconnect(error: String) { + func onDisconnect(error: String) { // Immediately invalidate our session pointer to prevent workQueue items from trying to use it. // Assigning to `nil` will invoke `Drop` on the Rust side. session = nil diff --git a/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift b/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift new file mode 100644 index 000000000..6d6012f7b --- /dev/null +++ b/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift @@ -0,0 +1,107 @@ +// +// ConfigurationManager.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// +// A wrapper around UserDefaults + +import Foundation +import FirezoneKit +import CryptoKit + +class ConfigurationManager { + static let shared = ConfigurationManager() + + let encoder = PropertyListEncoder() + + private var userDefaults: UserDefaults + + var authURL: URL? { + get { userDefaults.url(forKey: Configuration.Keys.authURL) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.authURL) } + } + + var apiURL: URL? { + get { userDefaults.url(forKey: Configuration.Keys.apiURL) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.apiURL) } + } + + var logFilter: String? { + get { userDefaults.string(forKey: Configuration.Keys.logFilter) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.logFilter) } + } + + var actorName: String? { + get { userDefaults.string(forKey: Configuration.Keys.actorName) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.actorName) } + } + + var accountSlug: String? { + get { userDefaults.string(forKey: Configuration.Keys.accountSlug) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.accountSlug) } + } + + var internetResourceEnabled: Bool? { + get { userDefaults.bool(forKey: Configuration.Keys.internetResourceEnabled) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.internetResourceEnabled) } + } + + var firezoneId: String? { + get { userDefaults.string(forKey: Configuration.Keys.firezoneId) } + set { userDefaults.set(newValue, forKey: Configuration.Keys.firezoneId) } + } + + private init() { + self.userDefaults = UserDefaults.standard + + if let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId), + let idFromFile = try? String(contentsOf: containerURL.appendingPathComponent("firezone-id")) { + + self.firezoneId = idFromFile + try? FileManager.default.removeItem(at: containerURL.appendingPathComponent("firezone-id")) + Telemetry.firezoneId = idFromFile + + return + } + + if let firezoneId { + Telemetry.firezoneId = firezoneId + return + } + + self.firezoneId = UUID().uuidString + Telemetry.firezoneId = firezoneId + } + + func toDataIfChanged(hash: Data?) -> Data? { + var dict: [String: Any] = [:] + + dict[Configuration.Keys.accountSlug] = accountSlug + dict[Configuration.Keys.actorName] = actorName + dict[Configuration.Keys.firezoneId] = firezoneId + dict[Configuration.Keys.internetResourceEnabled] = internetResourceEnabled + dict[Configuration.Keys.authURL] = authURL + dict[Configuration.Keys.apiURL] = apiURL + dict[Configuration.Keys.logFilter] = logFilter + + let configuration = Configuration(from: dict) + + do { + let encoded = try encoder.encode(configuration) + let hashData = Data(SHA256.hash(data: encoded)) + + if hash == hashData { + // same + return nil + } + + return encoded + + } catch { + Log.error(error) + } + + return nil + } +} diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 4257cf574..f222a9f46 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -10,7 +10,10 @@ import System import os enum PacketTunnelProviderError: Error { - case savedProtocolConfigurationIsInvalid(String) + case apiURLIsInvalid + case logFilterIsInvalid + case accountSlugIsInvalid + case firezoneIdIsInvalid case tokenNotFoundInKeychain } @@ -31,8 +34,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { super.init() } - // TODO: Refactor this to shorten function body - // swiftlint:disable:next function_body_length override func startTunnel( options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void @@ -50,49 +51,25 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // Try to save the token back to the Keychain but continue if we can't do { try token.save() } catch { Log.error(error) } - // Use and persist the provided ID or try loading it from disk, - // generating a new one if both of those are nil. - let id = loadAndSaveFirezoneId(from: options) + // The firezone id should be initialized by now + guard let id = ConfigurationManager.shared.firezoneId + else { + throw PacketTunnelProviderError.firezoneIdIsInvalid + } // Now we should have a token, so continue connecting - guard let apiURL = protocolConfiguration.serverAddress - else { - throw PacketTunnelProviderError - .savedProtocolConfigurationIsInvalid("serverAddress") - } + let apiURL = ConfigurationManager.shared.apiURL ?? Configuration.defaultApiURL // Reconfigure our Telemetry environment now that we know the API URL Telemetry.setEnvironmentOrClose(apiURL) - guard - let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)? - .providerConfiguration as? [String: String], - let logFilter = providerConfiguration[VPNConfigurationManager.Keys.logFilter] - else { - throw PacketTunnelProviderError - .savedProtocolConfigurationIsInvalid("providerConfiguration.logFilter") - } + let logFilter = ConfigurationManager.shared.logFilter ?? Configuration.defaultLogFilter // Hydrate telemetry account slug - 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 - // once the user fixes the problem, but we'd rather not connect - // without a slug. - throw PacketTunnelProviderError - .savedProtocolConfigurationIsInvalid("providerConfiguration.accountSlug") - } - + let accountSlug = ConfigurationManager.shared.accountSlug Telemetry.accountSlug = accountSlug - let internetResourceEnabled: Bool = - if let internetResourceEnabledJSON = providerConfiguration[ - VPNConfigurationManager.Keys.internetResourceEnabled]?.data(using: .utf8) { - (try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false - } else { - false - } + let internetResourceEnabled = ConfigurationManager.shared.internetResourceEnabled ?? false let adapter = Adapter( apiURL: apiURL, @@ -111,11 +88,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // `connected`. completionHandler(nil) - } catch let error as PacketTunnelProviderError { - - // These are expected, no need to log them - completionHandler(error) - } catch { Log.error(error) @@ -166,31 +138,71 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // It would be helpful to be able to encapsulate Errors here. To do that // we need to update ProviderMessage to encode/decode Result to and from Data. + // TODO: Move to a more abstract IPC protocol + // swiftlint:disable:next cyclomatic_complexity function_body_length override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { - guard let providerMessage = try? PropertyListDecoder().decode(ProviderMessage.self, from: message) else { return } + do { + let providerMessage = try PropertyListDecoder().decode(ProviderMessage.self, from: message) - switch providerMessage { - case .internetResourceEnabled(let value): - adapter?.setInternetResourceEnabled(value) - case .signOut: - do { - try Token.delete() - } catch { - Log.error(error) - } - case .getResourceList(let value): - adapter?.getResourcesIfVersionDifferentFrom(hash: value) { resourceListJSON in - completionHandler?(resourceListJSON?.data(using: .utf8)) - } - case .clearLogs: - clearLogs(completionHandler) - case .getLogFolderSize: - getLogFolderSize(completionHandler) - case .exportLogs: - exportLogs(completionHandler!) + switch providerMessage { + case .getConfiguration(let hash): + let configurationPayload = ConfigurationManager.shared.toDataIfChanged(hash: hash) + completionHandler?(configurationPayload) + case .setAuthURL(let authURL): + ConfigurationManager.shared.authURL = authURL + completionHandler?(nil) + case .setApiURL(let apiURL): + ConfigurationManager.shared.apiURL = apiURL + completionHandler?(nil) + case .setActorName(let actorName): + ConfigurationManager.shared.actorName = actorName + completionHandler?(nil) + case .setAccountSlug(let accountSlug): + ConfigurationManager.shared.accountSlug = accountSlug + completionHandler?(nil) + case .setLogFilter(let logFilter): + ConfigurationManager.shared.logFilter = logFilter + completionHandler?(nil) + case .setInternetResourceEnabled(let enabled): + ConfigurationManager.shared.internetResourceEnabled = enabled + adapter?.setInternetResourceEnabled(enabled) + completionHandler?(nil) + case .signOut: + do { + try Token.delete() + Task { + await stopTunnel(with: .userInitiated) + completionHandler?(nil) + } + } catch { + Log.error(error) + completionHandler?(nil) + } + case .getResourceList(let hash): + guard let adapter = adapter + else { + Log.warning("Adapter is nil") + completionHandler?(nil) - case .consumeStopReason: - consumeStopReason(completionHandler!) + return + } + + adapter.getResourcesIfVersionDifferentFrom(hash: hash) { resourceListJSON in + completionHandler?(resourceListJSON?.data(using: .utf8)) + } + case .clearLogs: + clearLogs(completionHandler) + case .getLogFolderSize: + getLogFolderSize(completionHandler) + case .exportLogs: + exportLogs(completionHandler!) + + case .consumeStopReason: + consumeStopReason(completionHandler!) + } + } catch { + Log.error(error) + completionHandler?(nil) } } @@ -208,20 +220,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return Token(passedToken) ?? keychainToken } - func loadAndSaveFirezoneId(from options: [String: NSObject]?) -> String { - let passedId = options?["id"] as? String - let persistedId = FirezoneId.load(.post140) - - let id = passedId ?? persistedId ?? UUID().uuidString - - FirezoneId.save(id) - - // Hydrate the telemetry userId with our firezone id - Telemetry.firezoneId = id - - return id - } - func clearLogs(_ completionHandler: ((Data?) -> Void)? = nil) { do { try Log.clear(in: SharedAccess.logFolderURL)