diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift index 286ba3dd0..fd7e14727 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift @@ -56,13 +56,17 @@ class IPCClient { let encoder = PropertyListEncoder() let decoder = PropertyListDecoder() - func start(token: String? = nil) throws { - var options: [String: NSObject] = [:] + // Auto-connect + func start() throws { + try session().startTunnel(options: nil) + } - // Pass token if provided - if let token = token { - options.merge(["token": token as NSObject]) { _, new in new } - } + // Sign in + func start(token: String, accountSlug: String) throws { + let options: [String: NSObject] = [ + "token": token as NSObject, + "accountSlug": accountSlug as NSObject + ] try session().startTunnel(options: options) } @@ -105,32 +109,8 @@ class IPCClient { } } - func setAuthURL(_ authURL: String) async throws { - try await sendMessageWithoutResponse(ProviderMessage.setAuthURL(authURL)) - } - - func setApiURL(_ apiURL: String) 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 setConnectOnStart(_ connectOnStart: Bool) async throws { - try await sendMessageWithoutResponse(ProviderMessage.setConnectOnStart(connectOnStart)) + func setConfiguration(_ configuration: Configuration) async throws { + try await sendMessageWithoutResponse(ProviderMessage.setConfiguration(configuration)) } func fetchResources() async throws -> ResourceList { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift index 225c288b0..c2f68c061 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift @@ -96,42 +96,46 @@ public class VPNConfigurationManager { let ipcClient = IPCClient(session: session) + var userDict: [String: Any?] = [:] var migrated = false + if let actorName = legacyConfiguration["actorName"] { + UserDefaults.standard.set(actorName, forKey: "actorName") + migrated = true + } + if let apiURL = legacyConfiguration["apiURL"] { - try await ipcClient.setApiURL(apiURL) + userDict[Configuration.Keys.apiURL] = apiURL migrated = true } if let authURL = legacyConfiguration["authBaseURL"] { - try await ipcClient.setAuthURL(authURL) - migrated = true - } - - if let actorName = legacyConfiguration["actorName"] { - try await ipcClient.setActorName(actorName) + userDict[Configuration.Keys.authURL] = authURL migrated = true } if let accountSlug = legacyConfiguration["accountSlug"] { - try await ipcClient.setAccountSlug(accountSlug) + userDict[Configuration.Keys.accountSlug] = accountSlug migrated = true } if let logFilter = legacyConfiguration["logFilter"], !logFilter.isEmpty { - try await ipcClient.setLogFilter(logFilter) + userDict[Configuration.Keys.logFilter] = logFilter migrated = true } if let internetResourceEnabled = legacyConfiguration["internetResourceEnabled"], ["false", "true"].contains(internetResourceEnabled) { - try await ipcClient.setInternetResourceEnabled(internetResourceEnabled == "true") + userDict[Configuration.Keys.internetResourceEnabled] = internetResourceEnabled == "true" migrated = true } if !migrated { return } + let configuration = Configuration(userDict: userDict, managedDict: [:]) + try await ipcClient.setConfiguration(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 { protocolConfiguration.providerConfiguration = nil diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift index 1128b9fd7..8229d21e8 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift @@ -11,11 +11,13 @@ public class Configuration: Codable { public static let defaultLogFilter = "info" #endif + public static let defaultAccountSlug = "" + public static let defaultConnectOnStart = true + public struct Keys { public static let authURL = "authURL" public static let apiURL = "apiURL" public static let logFilter = "logFilter" - public static let actorName = "actorName" public static let accountSlug = "accountSlug" public static let internetResourceEnabled = "internetResourceEnabled" public static let firezoneId = "firezoneId" @@ -24,7 +26,6 @@ public class Configuration: Codable { } public var authURL: String? - public var actorName: String? public var firezoneId: String? public var apiURL: String? public var logFilter: String? @@ -36,7 +37,6 @@ public class Configuration: Codable { private var overriddenKeys: Set = [] public init(userDict: [String: Any?], managedDict: [String: Any?]) { - self.actorName = userDict[Keys.actorName] as? String self.firezoneId = userDict[Keys.firezoneId] as? String setValue(forKey: Keys.authURL, from: managedDict, and: userDict) { [weak self] in self?.authURL = $0 } @@ -58,6 +58,14 @@ public class Configuration: Codable { return overriddenKeys.contains(key) } + func applySettings(_ settings: Settings) { + self.authURL = settings.authURL + self.apiURL = settings.apiURL + self.logFilter = settings.logFilter + self.accountSlug = settings.accountSlug + self.connectOnStart = settings.connectOnStart + } + private func setValue( forKey key: String, from managedDict: [String: Any?], diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift index 6246c9c6d..1f3540a17 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift @@ -7,20 +7,11 @@ import Foundation -// TODO: Can we simplify this / abstract it? -// swiftlint:disable cyclomatic_complexity - public enum ProviderMessage: Codable { case getResourceList(Data) case getConfiguration(Data) + case setConfiguration(Configuration) case signOut - case setAuthURL(String) - case setApiURL(String) - case setLogFilter(String) - case setActorName(String) - case setAccountSlug(String) - case setInternetResourceEnabled(Bool) - case setConnectOnStart(Bool) case clearLogs case getLogFolderSize case exportLogs @@ -34,14 +25,8 @@ public enum ProviderMessage: Codable { enum MessageType: String, Codable { case getResourceList case getConfiguration + case setConfiguration case signOut - case setAuthURL - case setApiURL - case setLogFilter - case setActorName - case setAccountSlug - case setInternetResourceEnabled - case setConnectOnStart case clearLogs case getLogFolderSize case exportLogs @@ -52,33 +37,15 @@ public enum ProviderMessage: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(MessageType.self, forKey: .type) switch type { - case .setAuthURL: - let value = try container.decode(String.self, forKey: .value) - self = .setAuthURL(value) - case .setApiURL: - let value = try container.decode(String.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 = .setInternetResourceEnabled(value) - case .setConnectOnStart: - let value = try container.decode(Bool.self, forKey: .value) - self = .setConnectOnStart(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 .setConfiguration: + let value = try container.decode(Configuration.self, forKey: .value) + self = .setConfiguration(value) case .signOut: self = .signOut case .clearLogs: @@ -95,33 +62,15 @@ public enum ProviderMessage: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - 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 .setConnectOnStart(let value): - try container.encode(MessageType.setConnectOnStart, 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 .setConfiguration(let value): + try container.encode(MessageType.setConfiguration, forKey: .type) + try container.encode(value, forKey: .value) case .signOut: try container.encode(MessageType.signOut, forKey: .type) case .clearLogs: @@ -135,5 +84,3 @@ 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 new file mode 100644 index 000000000..a63f33beb --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift @@ -0,0 +1,97 @@ +// +// Settings.swift +// © 2025 Firezone, Inc. +// LICENSE: Apache-2.0 +// +// Settings represents the binding between our source-of-truth, Configuration, and user-configurable settings +// available in the SettingsView. + +import Foundation + +class Settings { + @Published var authURL: String + @Published var apiURL: String + @Published var logFilter: String + @Published var accountSlug: String + @Published var connectOnStart: Bool + var isAuthURLOverridden = false + var isApiURLOverridden = false + var isLogFilterOverridden = false + var isAccountSlugOverridden = false + var isConnectOnStartOverridden = false + + private var configuration: Configuration + + init(from configuration: Configuration) { + self.configuration = configuration + self.authURL = configuration.authURL ?? Configuration.defaultAuthURL + self.apiURL = configuration.apiURL ?? Configuration.defaultApiURL + self.logFilter = configuration.logFilter ?? Configuration.defaultLogFilter + self.accountSlug = configuration.accountSlug ?? Configuration.defaultAccountSlug + self.connectOnStart = configuration.connectOnStart ?? Configuration.defaultConnectOnStart + + self.isAuthURLOverridden = configuration.isOverridden(Configuration.Keys.authURL) + self.isApiURLOverridden = configuration.isOverridden(Configuration.Keys.apiURL) + self.isLogFilterOverridden = configuration.isOverridden(Configuration.Keys.logFilter) + self.isAccountSlugOverridden = configuration.isOverridden(Configuration.Keys.accountSlug) + self.isConnectOnStartOverridden = configuration.isOverridden(Configuration.Keys.connectOnStart) + } + + func areAllFieldsOverridden() -> Bool { + return (isAuthURLOverridden && + isApiURLOverridden && + isLogFilterOverridden && + isAccountSlugOverridden && + isConnectOnStartOverridden) + } + + func isValid() -> Bool { + guard let apiURL = URL(string: apiURL), + apiURL.host != nil, + ["wss", "ws"].contains(apiURL.scheme), + apiURL.pathComponents.isEmpty + else { + return false + } + + guard let authURL = URL(string: authURL), + authURL.host != nil, + ["http", "https"].contains(authURL.scheme), + authURL.pathComponents.isEmpty + else { + return false + } + + guard !logFilter.isEmpty + else { + return false + } + + return true + } + + func isDefault() -> Bool { + return (authURL == Configuration.defaultAuthURL && + apiURL == Configuration.defaultApiURL && + logFilter == Configuration.defaultLogFilter && + accountSlug == Configuration.defaultAccountSlug && + connectOnStart == Configuration.defaultConnectOnStart) + } + + func isSaved() -> Bool { + return ( + authURL == configuration.authURL && + apiURL == configuration.apiURL && + logFilter == configuration.logFilter && + accountSlug == configuration.accountSlug && + connectOnStart == configuration.connectOnStart) + } + + func reset() { + self.authURL = Configuration.defaultAuthURL + self.apiURL = Configuration.defaultApiURL + self.logFilter = Configuration.defaultLogFilter + self.accountSlug = Configuration.defaultAccountSlug + self.connectOnStart = Configuration.defaultConnectOnStart + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 0273a9a51..07e49a0fe 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -15,12 +15,15 @@ import AppKit @MainActor // TODO: Move some state logic to view models -// swiftlint:disable:next type_body_length public final class Store: ObservableObject { + @Published private(set) var actorName: String @Published private(set) var favorites = Favorites() @Published private(set) var resourceList: ResourceList = .loading - // UserDefaults-backed app configuration that will publish updates to SwiftUI components + // User-configurable settings + @Published private(set) var settings: Settings? + + // UserDefaults-backed app configuration @Published private(set) var configuration: Configuration? // Enacapsulate Tunnel status here to make it easier for other components to observe @@ -46,6 +49,9 @@ public final class Store: ObservableObject { private var vpnConfigurationManager: VPNConfigurationManager? public init() { + // Load GUI-only cached state + self.actorName = UserDefaults.standard.string(forKey: "actorName") ?? "Unknown user" + self.sessionNotification.signInHandler = { Task { do { try await WebAuthSession.signIn(store: self) } catch { Log.error(error) } @@ -84,10 +90,10 @@ public final class Store: ObservableObject { try await manager.maybeMigrateConfiguration() self.vpnConfigurationManager = manager try await setupTunnelObservers() - try await manager.enableConfiguration() self.configuration = try await ipcClient().getConfiguration() Telemetry.firezoneId = configuration?.firezoneId + try await manager.enableConfiguration() if configuration?.connectOnStart ?? true { try ipcClient().start() } @@ -221,17 +227,18 @@ public final class Store: ObservableObject { } func signIn(authResponse: AuthResponse) async throws { - // Save actorName - try await setActorName(authResponse.actorName) + let actorName = authResponse.actorName + let accountSlug = authResponse.accountSlug - // This will save the account slug even if overridden from MDM. This is what we want - if the admin removes the - // override, this will take effect again. - try await setAccountSlug(authResponse.accountSlug) + // This is only shown in the GUI, cache it here + UserDefaults.standard.set(actorName, forKey: "actorName") + + Telemetry.accountSlug = accountSlug try await manager().enableConfiguration() // Bring the tunnel up and send it a token to start - try ipcClient().start(token: authResponse.token) + try ipcClient().start(token: authResponse.token, accountSlug: accountSlug) } func signOut() async throws { @@ -249,53 +256,18 @@ public final class Store: ObservableObject { // MARK: App configuration setters - func setActorName(_ actorName: String) async throws { - try await ipcClient().setActorName(actorName) - configuration?.actorName = actorName + func applySettingsToConfiguration(_ settings: Settings) async throws { + configuration?.applySettings(settings) + try await setConfiguration(configuration) } - func setAccountSlug(_ accountSlug: String) async throws { - try await ipcClient().setAccountSlug(accountSlug) - configuration?.accountSlug = accountSlug - - // Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure. - Telemetry.accountSlug = accountSlug - } - - func setAuthURL(_ authURL: String) async throws { - try await ipcClient().setAuthURL(authURL) - configuration?.authURL = authURL - } - - func setApiURL(_ apiURL: String) async throws { - try await ipcClient().setApiURL(apiURL) - configuration?.apiURL = apiURL - - // Reconfigure our Telemetry environment in case it changed - Telemetry.setEnvironmentOrClose(apiURL) - } - - func setLogFilter(_ logFilter: String) async throws { - try await ipcClient().setLogFilter(logFilter) - configuration?.logFilter = logFilter - } - - func setInternetResourceEnabled(_ internetResourceEnabled: Bool) async throws { - try await ipcClient().setInternetResourceEnabled(internetResourceEnabled) + private func setInternetResourceEnabled(_ internetResourceEnabled: Bool) async throws { configuration?.internetResourceEnabled = internetResourceEnabled - } - - func setConnectOnStart(_ connectOnStart: Bool) async throws { - try await ipcClient().setConnectOnStart(connectOnStart) - configuration?.connectOnStart = connectOnStart + try await setConfiguration(configuration) } // MARK: Private functions - private func start(token: String? = nil) throws { - try ipcClient().start(token: token) - } - private func beginConfigurationPolling() { // Ensure we're idempotent if called twice if self.configurationTimer != nil { @@ -378,4 +350,15 @@ public final class Store: ObservableObject { resourcesTimer = nil resourceList = ResourceList.loading } + + private func setConfiguration(_ configuration: Configuration?) async throws { + guard let configuration = configuration + else { + Log.warning("Tried to set configuration before it was initialized") + return + } + + try await ipcClient().setConfiguration(configuration) + self.configuration = configuration + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index ca0a5da58..75f18e84c 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -210,6 +210,13 @@ public final class MenuBar: NSObject, ObservableObject { self.handleStatusChanged() }).store(in: &cancellables) + store.$configuration + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] _ in + self?.updateSignInMenuItems() + }) + .store(in: &cancellables) + updateChecker.$updateAvailable .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] _ in @@ -350,7 +357,7 @@ public final class MenuBar: NSObject, ObservableObject { signOutMenuItem.isHidden = true settingsMenuItem.target = self case .connected, .reasserting, .connecting: - let title = "Signed in as \(store.configuration?.actorName ?? "Unknown User")" + let title = "Signed in as \(store.actorName)" signInMenuItem.title = title signInMenuItem.target = nil signOutMenuItem.isHidden = false @@ -358,6 +365,11 @@ public final class MenuBar: NSObject, ObservableObject { @unknown default: break } + + // Configuration must be initialized to manage settings + if store.configuration == nil { + settingsMenuItem.target = nil + } } func updateResourcesMenuItems() { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index 9e1f71406..7f4964f29 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -13,11 +13,20 @@ import SwiftUI enum SettingsViewError: Error { case logFolderIsUnavailable + case configurationNotInitialized var localizedDescription: String { switch self { case .logFolderIsUnavailable: - return "Log folder is unavailable." + return """ + Log folder is unavailable. + Try restarting your device or reinstalling Firezone if this issue persists. + """ + case .configurationNotInitialized: + return """ + Configuration is not initialized. + Try restarting your device or reinstalling Firezone if this issue persists. + """ } } } @@ -69,153 +78,78 @@ class SettingsViewModel: ObservableObject { private let store: Store private var cancellables = Set() - @Published var authURL: String - @Published var apiURL: String - @Published var logFilter: String - @Published var accountSlug: String - @Published var connectOnStart: Bool - @Published private(set) var isAuthURLOverridden = false - @Published private(set) var isApiURLOverridden = false - @Published private(set) var isLogFilterOverridden = false - @Published private(set) var isAccountSlugOverridden = false - @Published private(set) var isConnectOnStartOverridden = false + @Published var settings: Settings + @Published private(set) var shouldDisableApplyButton = false @Published private(set) var shouldDisableResetButton = false - @Published private(set) var areSettingsDefault = true - @Published private(set) var areSettingsValid = true - @Published private(set) var areSettingsSaved = true init(store: Store) { self.store = store - self.authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL - self.apiURL = store.configuration?.apiURL ?? Configuration.defaultApiURL - self.logFilter = store.configuration?.logFilter ?? Configuration.defaultLogFilter - self.accountSlug = store.configuration?.accountSlug ?? "" - self.connectOnStart = store.configuration?.connectOnStart ?? true - - updateDerivedState() - - // Update our state from our text fields - Publishers.CombineLatest( - Publishers.CombineLatest4($authURL, $apiURL, $logFilter, $accountSlug), - $connectOnStart - ) - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self = self else { return } - - self.updateDerivedState() + guard let configuration = store.configuration + else { + fatalError("Configuration must be initialized to view settings") } + + self.settings = Settings(from: configuration) + setupObservers() + + // TODO: Handle updates to configuration while the SettingsView is open? + } + + func reset() { + settings.reset() + objectWillChange.send() + updateDerivedState() + } + + func save() async throws { + try await store.applySettingsToConfiguration(settings) + + guard let configuration = store.configuration + else { + throw SettingsViewError.configurationNotInitialized + } + + self.settings = Settings(from: configuration) + setupObservers() + } + + private func setupObservers() { + // These all need to be the same underlying type + Publishers.MergeMany([ + settings.$authURL, + settings.$apiURL, + settings.$accountSlug, + settings.$logFilter + ]) + .receive(on: RunLoop.main) + .sink(receiveValue: { [weak self] _ in + self?.updateDerivedState() + }) .store(in: &cancellables) - // Update our state from configuration updates - store.$configuration + settings.$connectOnStart .receive(on: RunLoop.main) - .sink { [weak self] newConfiguration in - self?.isAuthURLOverridden = newConfiguration?.isOverridden(Configuration.Keys.authURL) ?? false - self?.isApiURLOverridden = newConfiguration?.isOverridden(Configuration.Keys.apiURL) ?? false - self?.isLogFilterOverridden = newConfiguration?.isOverridden(Configuration.Keys.logFilter) ?? false - self?.isAccountSlugOverridden = newConfiguration?.isOverridden(Configuration.Keys.accountSlug) ?? false - self?.isConnectOnStartOverridden = newConfiguration?.isOverridden(Configuration.Keys.connectOnStart) ?? false - + .sink(receiveValue: { [weak self] _ in self?.updateDerivedState() - } + }) .store(in: &cancellables) } - private func isAuthURLValid() -> Bool { - if let authURL = URL(string: authURL), - authURL.host != nil, - ["https", "http"].contains(authURL.scheme), - // Be restrictive - account slug will be appended - authURL.pathComponents.isEmpty { - - return true - } - - return false - } - - private func isApiURLValid() -> Bool { - if let apiURL = URL(string: apiURL), - apiURL.host != nil, - ["wss", "ws"].contains(apiURL.scheme), - // Be restrictive - account slug will be appended - apiURL.pathComponents.isEmpty { - - return true - } - - return false - } - - private func isLogFilterValid() -> Bool { - return !logFilter.isEmpty - } - - private func isAccountSlugValid() -> Bool { - // URL automatically percent-encodes - return true - } - private func updateDerivedState() { - self.areSettingsSaved = (self.authURL == store.configuration?.authURL && - self.apiURL == store.configuration?.apiURL && - self.logFilter == store.configuration?.logFilter && - self.accountSlug == store.configuration?.accountSlug && - self.connectOnStart == store.configuration?.connectOnStart) - - self.areSettingsValid = isAuthURLValid() && isApiURLValid() && isLogFilterValid() && isAccountSlugValid() - - self.areSettingsDefault = (self.authURL == Configuration.defaultAuthURL && - self.apiURL == Configuration.defaultApiURL && - self.logFilter == Configuration.defaultLogFilter && - self.accountSlug == "" && - self.connectOnStart == true) - self.shouldDisableApplyButton = ( - isAuthURLOverridden && - isApiURLOverridden && - isLogFilterOverridden && - isAccountSlugOverridden && - isConnectOnStartOverridden - ) || areSettingsSaved || !areSettingsValid + settings.areAllFieldsOverridden() || + settings.isSaved() || + !settings.isValid() + ) self.shouldDisableResetButton = ( - isAuthURLOverridden && - isApiURLOverridden && - isLogFilterOverridden && - isAccountSlugOverridden && - isConnectOnStartOverridden - ) || areSettingsDefault + settings.areAllFieldsOverridden() || + settings.isDefault() + ) } - func applySettingsToStore() async throws { - try await store.setApiURL(apiURL) - try await store.setLogFilter(logFilter) - try await store.setAuthURL(authURL) - try await store.setAccountSlug(accountSlug) - try await store.setConnectOnStart(connectOnStart) - - updateDerivedState() - } - - func revertToDefaultSettings() { - self.authURL = Configuration.defaultAuthURL - self.apiURL = Configuration.defaultApiURL - self.logFilter = Configuration.defaultLogFilter - self.accountSlug = "" - self.connectOnStart = true - } - - func reloadSettingsFromStore() { - self.authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL - self.apiURL = store.configuration?.apiURL ?? Configuration.defaultApiURL - self.logFilter = store.configuration?.logFilter ?? Configuration.defaultLogFilter - self.accountSlug = store.configuration?.accountSlug ?? "" - self.connectOnStart = store.configuration?.connectOnStart ?? true - } } // TODO: Move business logic to ViewModel to remove dependency on Store and fix body length @@ -298,7 +232,7 @@ public struct SettingsView: View { Image(systemName: "gearshape.2") Text("Advanced") } - .badge(viewModel.areSettingsValid ? nil : "!") + .badge(viewModel.settings.isValid() ? nil : "!") logsTab .tabItem { Image(systemName: "doc.text") @@ -321,9 +255,7 @@ public struct SettingsView: View { .disabled(viewModel.shouldDisableApplyButton) } ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - self.reloadSettings() - } + Button("Cancel") { dismiss() } } } .navigationTitle("Settings") @@ -372,7 +304,7 @@ public struct SettingsView: View { Button( "Reset to Defaults", action: { - viewModel.revertToDefaultSettings() + viewModel.reset() } ) .disabled(viewModel.shouldDisableResetButton) @@ -412,7 +344,6 @@ public struct SettingsView: View { Text("Changing settings will sign you out and disconnect you from resources") } ) - .onDisappear(perform: { self.reloadSettings() }) #else #error("Unsupported platform") #endif @@ -430,18 +361,18 @@ public struct SettingsView: View { .frame(width: 150, alignment: .trailing) TextField( "", - text: $viewModel.accountSlug, + text: $viewModel.settings.accountSlug, prompt: Text(PlaceholderText.accountSlug) ) - .disabled(viewModel.isAccountSlugOverridden) + .disabled(viewModel.settings.isAccountSlugOverridden) .frame(width: 250) } .padding(.bottom, 10) - Toggle(isOn: $viewModel.connectOnStart) { + Toggle(isOn: $viewModel.settings.connectOnStart) { Text("Connect on launch") } .toggleStyle(.checkbox) - .disabled(viewModel.isConnectOnStartOverridden) + .disabled(viewModel.settings.isConnectOnStartOverridden) } .padding(10) Spacer() @@ -459,21 +390,21 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.accountSlug, - text: $viewModel.accountSlug + text: $viewModel.settings.accountSlug ) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) - .disabled(viewModel.isAccountSlugOverridden) + .disabled(viewModel.settings.isAccountSlugOverridden) .padding(.bottom, 10) Spacer() - Toggle(isOn: $viewModel.connectOnStart) { + Toggle(isOn: $viewModel.settings.connectOnStart) { Text("Connect on launch") } .toggleStyle(.switch) - .disabled(viewModel.isConnectOnStartOverridden) + .disabled(viewModel.settings.isConnectOnStartOverridden) } }, header: { Text("General Settings") }, @@ -509,10 +440,10 @@ public struct SettingsView: View { .frame(width: 150, alignment: .trailing) TextField( "", - text: $viewModel.authURL, + text: $viewModel.settings.authURL, prompt: Text(PlaceholderText.authBaseURL) ) - .disabled(viewModel.isAuthURLOverridden) + .disabled(viewModel.settings.isAuthURLOverridden) .frame(width: 250) } @@ -522,10 +453,10 @@ public struct SettingsView: View { .frame(width: 150, alignment: .trailing) TextField( "", - text: $viewModel.apiURL, + text: $viewModel.settings.apiURL, prompt: Text(PlaceholderText.apiURL) ) - .disabled(viewModel.isApiURLOverridden) + .disabled(viewModel.settings.isApiURLOverridden) .frame(width: 250) } @@ -535,10 +466,10 @@ public struct SettingsView: View { .frame(width: 150, alignment: .trailing) TextField( "", - text: $viewModel.logFilter, + text: $viewModel.settings.logFilter, prompt: Text(PlaceholderText.logFilter) ) - .disabled(viewModel.isLogFilterOverridden) + .disabled(viewModel.settings.isLogFilterOverridden) .frame(width: 250) } } @@ -559,12 +490,12 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.authBaseURL, - text: $viewModel.authURL + text: $viewModel.settings.authURL ) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) - .disabled(viewModel.isAuthURLOverridden) + .disabled(viewModel.settings.isAuthURLOverridden) } VStack(alignment: .leading, spacing: 2) { Text("API URL") @@ -572,12 +503,12 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.apiURL, - text: $viewModel.apiURL + text: $viewModel.settings.apiURL ) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) - .disabled(viewModel.isApiURLOverridden) + .disabled(viewModel.settings.isApiURLOverridden) } VStack(alignment: .leading, spacing: 2) { Text("Log Filter") @@ -585,19 +516,19 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.logFilter, - text: $viewModel.logFilter + text: $viewModel.settings.logFilter ) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) - .disabled(viewModel.isLogFilterOverridden) + .disabled(viewModel.settings.isLogFilterOverridden) } HStack { Spacer() Button( "Reset to Defaults", action: { - viewModel.revertToDefaultSettings() + viewModel.reset() } ) .disabled(viewModel.shouldDisableResetButton) @@ -734,11 +665,6 @@ public struct SettingsView: View { dismiss() } - private func reloadSettings() { - viewModel.reloadSettingsFromStore() - dismiss() - } - #if os(macOS) private func exportLogsWithSavePanelOnMac() { self.isExportingLogs = true @@ -830,7 +756,7 @@ public struct SettingsView: View { } private func saveSettings() async throws { - try await viewModel.applySettingsToStore() + try await viewModel.save() if [.connected, .connecting, .reasserting].contains(store.status) { // TODO: Warn user instead of signing out diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift index d20353c6a..f626a73cd 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift @@ -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.configuration?.actorName ?? "Unknown user")") + Text("Signed in as \(store.actorName)") Button( action: { signOutButtonTapped() diff --git a/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift b/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift index 86c52eb2b..2d87cfcf3 100644 --- a/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift +++ b/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift @@ -19,9 +19,9 @@ class ConfigurationManager { // We maintain a cache of the user dictionary to buffer against unnecessary reads from UserDefaults which // can cause deadlocks in rare cases. - var userDict: [String: Any?] + private var userDict: [String: Any?] - var managedDict: [String: Any?] { + private var managedDict: [String: Any?] { userDefaults.dictionary(forKey: managedDictKey) ?? [:] } @@ -33,39 +33,19 @@ class ConfigurationManager { Telemetry.firezoneId = userDict[Configuration.Keys.firezoneId] as? String } - func setAuthURL(_ authURL: String) { - userDict[Configuration.Keys.authURL] = authURL + // Save user-settable configuration + func setConfiguration(_ configuration: Configuration) { + userDict[Configuration.Keys.authURL] = configuration.authURL + userDict[Configuration.Keys.apiURL] = configuration.apiURL + userDict[Configuration.Keys.logFilter] = configuration.logFilter + userDict[Configuration.Keys.accountSlug] = configuration.accountSlug + userDict[Configuration.Keys.connectOnStart] = configuration.connectOnStart + saveUserDict() } - func setApiURL(_ apiURL: String) { - userDict[Configuration.Keys.apiURL] = apiURL - saveUserDict() - } - - func setLogFilter(_ logFilter: String) { - userDict[Configuration.Keys.logFilter] = logFilter - saveUserDict() - } - - func setActorName(_ actorName: String) { - userDict[Configuration.Keys.actorName] = actorName - saveUserDict() - } - - func setAccountSlug(_ accountSlug: String) { - userDict[Configuration.Keys.accountSlug] = accountSlug - saveUserDict() - } - - func setInternetResourceEnabled(_ internetResourceEnabled: Bool) { - userDict[Configuration.Keys.internetResourceEnabled] = internetResourceEnabled - saveUserDict() - } - - func setConnectOnStart(_ connectOnStart: Bool) { - userDict[Configuration.Keys.connectOnStart] = connectOnStart - saveUserDict() + func toConfiguration() -> Configuration { + return Configuration(userDict: userDict, managedDict: managedDict) } // Firezone ID migration. Can be removed once most clients migrate past 1.4.15. diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index d3319461c..654cf7346 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -32,10 +32,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // Initialize Telemetry as early as possible Telemetry.start() - self.configuration = Configuration( - userDict: ConfigurationManager.shared.userDict, - managedDict: ConfigurationManager.shared.managedDict - ) + self.configuration = ConfigurationManager.shared.toConfiguration() super.init() } @@ -78,10 +75,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let logFilter = legacyConfiguration?["logFilter"] ?? configuration.logFilter ?? Configuration.defaultLogFilter - guard let accountSlug = legacyConfiguration?["accountSlug"] ?? configuration.accountSlug + // Prioritize passed accountSlug, updating saved account slug for next connect + guard let accountSlug = options?["accountSlug"] as? String ?? + legacyConfiguration?["accountSlug"] ?? + configuration.accountSlug else { throw PacketTunnelProviderError.accountSlugIsInvalid } + configuration.accountSlug = accountSlug + ConfigurationManager.shared.setConfiguration(configuration) Telemetry.accountSlug = accountSlug let enabled = legacyConfiguration?["internetResourceEnabled"] @@ -157,7 +159,7 @@ 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 + // swiftlint:disable:next cyclomatic_complexity override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { do { let providerMessage = try PropertyListDecoder().decode(ProviderMessage.self, from: message) @@ -168,40 +170,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let configurationPayload = configuration.toDataIfChanged(hash: hash) completionHandler?(configurationPayload) - case .setAuthURL(let authURL): - configuration.authURL = authURL - ConfigurationManager.shared.setAuthURL(authURL) - completionHandler?(nil) - - case .setApiURL(let apiURL): - configuration.apiURL = apiURL - ConfigurationManager.shared.setApiURL(apiURL) - completionHandler?(nil) - - case .setActorName(let actorName): - configuration.actorName = actorName - ConfigurationManager.shared.setActorName(actorName) - completionHandler?(nil) - - case .setAccountSlug(let accountSlug): - configuration.accountSlug = accountSlug - ConfigurationManager.shared.setAccountSlug(accountSlug) - completionHandler?(nil) - - case .setLogFilter(let logFilter): - configuration.logFilter = logFilter - ConfigurationManager.shared.setLogFilter(logFilter) - completionHandler?(nil) - - case .setInternetResourceEnabled(let enabled): - configuration.internetResourceEnabled = enabled - ConfigurationManager.shared.setInternetResourceEnabled(enabled) - adapter?.setInternetResourceEnabled(enabled) - completionHandler?(nil) - - case .setConnectOnStart(let connectOnStart): - configuration.connectOnStart = connectOnStart - ConfigurationManager.shared.setConnectOnStart(connectOnStart) + case .setConfiguration(let configuration): + self.configuration = configuration + ConfigurationManager.shared.setConfiguration(configuration) completionHandler?(nil) case .signOut: