diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj index 1da2342bc..f35668dba 100644 --- a/swift/apple/Firezone.xcodeproj/project.pbxproj +++ b/swift/apple/Firezone.xcodeproj/project.pbxproj @@ -32,8 +32,6 @@ 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, ); }; }; @@ -110,7 +108,6 @@ 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; }; @@ -174,7 +171,6 @@ 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */, 6FE454EA2A5BFABA006549B1 /* Adapter.swift */, 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */, - 8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */, 6FE455082A5D110D006549B1 /* CallbackHandler.swift */, 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */, 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */, @@ -496,7 +492,6 @@ 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 */, @@ -517,7 +512,6 @@ 8D5048002CE6AA60009802E9 /* SystemConfigurationResolvers.swift in Sources */, 8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */, 8D5047FF2CE6AA54009802E9 /* Adapter.swift in Sources */, - 8DA9BFD22DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift index eb485d790..f0851bd3b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/IPCClient.swift @@ -41,10 +41,6 @@ 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 } @@ -59,10 +55,9 @@ class IPCClient { } // Sign in - func start(token: String, accountSlug: String) throws { + func start(token: String) throws { let options: [String: NSObject] = [ - "token": token as NSObject, - "accountSlug": accountSlug as NSObject + "token": token as NSObject ] try session().startTunnel(options: options) @@ -86,38 +81,12 @@ class IPCClient { } #endif - 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) - } - } - } - + @MainActor func setConfiguration(_ configuration: Configuration) async throws { - try await sendMessageWithoutResponse(ProviderMessage.setConfiguration(configuration)) + let tunnelConfiguration = configuration.toTunnelConfiguration() + let message = ProviderMessage.setConfiguration(tunnelConfiguration) + + try await sendMessageWithoutResponse(message) } 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 c2f68c061..b7bc1e438 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift @@ -73,7 +73,7 @@ public class VPNConfigurationManager { // 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 { + func enable() async throws { manager.isEnabled = true try await manager.saveToPreferences() try await manager.loadFromPreferences() @@ -85,6 +85,7 @@ public class VPNConfigurationManager { // 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. + @MainActor func maybeMigrateConfiguration() async throws { guard let legacyConfiguration = Self.legacyConfiguration( protocolConfiguration: manager.protocolConfiguration as? NETunnelProviderProtocol @@ -94,46 +95,35 @@ public class VPNConfigurationManager { return } + let configuration = Configuration.shared 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"] { - userDict[Configuration.Keys.apiURL] = apiURL - migrated = true + configuration.apiURL = apiURL } if let authURL = legacyConfiguration["authBaseURL"] { - userDict[Configuration.Keys.authURL] = authURL - migrated = true + configuration.authURL = authURL } if let accountSlug = legacyConfiguration["accountSlug"] { - userDict[Configuration.Keys.accountSlug] = accountSlug - migrated = true + configuration.accountSlug = accountSlug } if let logFilter = legacyConfiguration["logFilter"], !logFilter.isEmpty { - userDict[Configuration.Keys.logFilter] = logFilter - migrated = true + configuration.logFilter = logFilter } if let internetResourceEnabled = legacyConfiguration["internetResourceEnabled"], ["false", "true"].contains(internetResourceEnabled) { - userDict[Configuration.Keys.internetResourceEnabled] = internetResourceEnabled == "true" - migrated = true + configuration.internetResourceEnabled = internetResourceEnabled == "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. diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift index 0cb5810c5..5eee42c6c 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Configuration.swift @@ -1,99 +1,178 @@ +// +// Configuration.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// +// A thin wrapper around UserDefaults for user and admin managed app configuration. + import Foundation -public class Configuration: Codable { -#if DEBUG - public static let defaultAuthURL = "https://app.firez.one" - public static let defaultApiURL = "wss://api.firez.one" - public static let defaultLogFilter = "debug" -#else - public static let defaultAuthURL = "https://app.firezone.dev" - public static let defaultApiURL = "wss://api.firezone.dev" - public static let defaultLogFilter = "info" +#if os(macOS) +import ServiceManagement #endif - public static let defaultAccountSlug = "" - public static let defaultConnectOnStart = true - public static let defaultStartOnLogin = false - public static let defaultDisableUpdateCheck = false +@MainActor +public class Configuration: ObservableObject { + static let shared = Configuration() - public struct Keys { - public static let authURL = "authURL" - public static let apiURL = "apiURL" - public static let logFilter = "logFilter" - public static let accountSlug = "accountSlug" - public static let internetResourceEnabled = "internetResourceEnabled" - public static let firezoneId = "firezoneId" - public static let hideAdminPortalMenuItem = "hideAdminPortalMenuItem" - public static let connectOnStart = "connectOnStart" - public static let startOnLogin = "startOnLogin" - public static let disableUpdateCheck = "disableUpdateCheck" + @Published private(set) var publishedInternetResourceEnabled = false + @Published private(set) var publishedInternetResourceForced = false + @Published private(set) var publishedHideAdminPortalMenuItem = false + + var isAuthURLForced: Bool { defaults.objectIsForced(forKey: Keys.authURL) } + var isApiURLForced: Bool { defaults.objectIsForced(forKey: Keys.apiURL) } + var isLogFilterForced: Bool { defaults.objectIsForced(forKey: Keys.logFilter) } + var isAccountSlugForced: Bool { defaults.objectIsForced(forKey: Keys.accountSlug) } + var isConnectOnStartForced: Bool { defaults.objectIsForced(forKey: Keys.connectOnStart) } + var isStartOnLoginForced: Bool { defaults.objectIsForced(forKey: Keys.startOnLogin) } + var isInternetResourceForced: Bool { defaults.objectIsForced(forKey: Keys.internetResourceEnabled) } + + var authURL: String { + get { defaults.string(forKey: Keys.authURL) ?? Self.defaultAuthURL } + set { defaults.set(newValue, forKey: Keys.authURL) } } - public var authURL: String? - public var firezoneId: String? - public var apiURL: String? - public var logFilter: String? - public var accountSlug: String? - public var internetResourceEnabled: Bool? - public var hideAdminPortalMenuItem: Bool? - public var connectOnStart: Bool? - public var startOnLogin: Bool? - public var disableUpdateCheck: Bool? - - private var overriddenKeys: Set = [] - - public init(userDict: [String: Any?], managedDict: [String: Any?]) { - self.firezoneId = userDict[Keys.firezoneId] as? String - - setValue(forKey: Keys.authURL, from: managedDict, and: userDict) { [weak self] in self?.authURL = $0 } - setValue(forKey: Keys.apiURL, from: managedDict, and: userDict) { [weak self] in self?.apiURL = $0 } - setValue(forKey: Keys.logFilter, from: managedDict, and: userDict) { [weak self] in self?.logFilter = $0 } - setValue(forKey: Keys.accountSlug, from: managedDict, and: userDict) { [weak self] in self?.accountSlug = $0 } - setValue(forKey: Keys.internetResourceEnabled, from: managedDict, and: userDict) { [weak self] in - self?.internetResourceEnabled = $0 - } - setValue(forKey: Keys.hideAdminPortalMenuItem, from: managedDict, and: userDict) { [weak self] in - self?.hideAdminPortalMenuItem = $0 - } - setValue(forKey: Keys.connectOnStart, from: managedDict, and: userDict) { [weak self] in - self?.connectOnStart = $0 - } - setValue(forKey: Keys.startOnLogin, from: managedDict, and: userDict) { [weak self] in - self?.startOnLogin = $0 - } - setValue(forKey: Keys.disableUpdateCheck, from: managedDict, and: userDict) { [weak self] in - self?.disableUpdateCheck = $0 - } + var apiURL: String { + get { defaults.string(forKey: Keys.apiURL) ?? Self.defaultApiURL } + set { defaults.set(newValue, forKey: Keys.apiURL) } } - func isOverridden(_ key: String) -> Bool { - return overriddenKeys.contains(key) + var logFilter: String { + get { defaults.string(forKey: Keys.logFilter) ?? Self.defaultLogFilter } + set { defaults.set(newValue, forKey: Keys.logFilter) } } - func applySettings(_ settings: Settings) { - self.authURL = settings.authURL - self.apiURL = settings.apiURL - self.logFilter = settings.logFilter - self.accountSlug = settings.accountSlug - self.connectOnStart = settings.connectOnStart - self.startOnLogin = settings.startOnLogin + var accountSlug: String { + get { defaults.string(forKey: Keys.accountSlug) ?? Self.defaultAccountSlug } + set { defaults.set(newValue, forKey: Keys.accountSlug) } } - private func setValue( - forKey key: String, - from managedDict: [String: Any?], - and userDict: [String: Any?], - setter: (T) -> Void - ) { - if let value = managedDict[key], - let typedValue = value as? T { - overriddenKeys.insert(key) - return setter(typedValue) - } + var internetResourceEnabled: Bool { + get { defaults.bool(forKey: Keys.internetResourceEnabled) } + set { defaults.set(newValue, forKey: Keys.internetResourceEnabled) } + } - if let value = userDict[key], - let typedValue = value as? T { - setter(typedValue) + var hideAdminPortalMenuItem: Bool { + get { defaults.bool(forKey: Keys.hideAdminPortalMenuItem) } + set { defaults.set(newValue, forKey: Keys.hideAdminPortalMenuItem) } + } + + var connectOnStart: Bool { + get { defaults.bool(forKey: Keys.connectOnStart) } + set { defaults.set(newValue, forKey: Keys.connectOnStart) } + } + + var startOnLogin: Bool { + get { defaults.bool(forKey: Keys.startOnLogin) } + set { defaults.set(newValue, forKey: Keys.startOnLogin) } + } + + var disableUpdateCheck: Bool { + get { defaults.bool(forKey: Keys.disableUpdateCheck) } + set { defaults.set(newValue, forKey: Keys.disableUpdateCheck) } + } + +#if DEBUG + static let defaultAuthURL = "https://app.firez.one" + static let defaultApiURL = "wss://api.firez.one" + static let defaultLogFilter = "debug" +#else + static let defaultAuthURL = "https://app.firezone.dev" + static let defaultApiURL = "wss://api.firezone.dev" + static let defaultLogFilter = "info" +#endif + + static let defaultAccountSlug = "" + static let defaultConnectOnStart = true + static let defaultStartOnLogin = false + static let defaultDisableUpdateCheck = false + + private struct Keys { + static let authURL = "authURL" + static let apiURL = "apiURL" + static let logFilter = "logFilter" + static let accountSlug = "accountSlug" + static let internetResourceEnabled = "internetResourceEnabled" + static let hideAdminPortalMenuItem = "hideAdminPortalMenuItem" + static let connectOnStart = "connectOnStart" + static let startOnLogin = "startOnLogin" + static let disableUpdateCheck = "disableUpdateCheck" + } + + private var defaults: UserDefaults + + init(userDefaults: UserDefaults = UserDefaults.standard) { + defaults = userDefaults + + self.publishedInternetResourceEnabled = internetResourceEnabled + self.publishedInternetResourceForced = isInternetResourceForced + self.publishedHideAdminPortalMenuItem = hideAdminPortalMenuItem + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleUserDefaultsChanged), + name: UserDefaults.didChangeNotification, + object: defaults + ) + } + + deinit { + NotificationCenter.default.removeObserver(self, name: UserDefaults.didChangeNotification, object: defaults) + } + + func toTunnelConfiguration() -> TunnelConfiguration { + return TunnelConfiguration( + apiURL: apiURL, + accountSlug: accountSlug, + logFilter: logFilter, + internetResourceEnabled: internetResourceEnabled + ) + } + +#if os(macOS) + // Register / unregister our launch service based on configuration. This is a major pain to do on macOS 12 and below, + // so this feature only enabled for macOS 13 and higher given the tiny Firezone installbase for macOS 12. + func updateAppService() async throws { + if #available(macOS 13.0, *) { + if !startOnLogin, SMAppService.mainApp.status == .enabled { + try await SMAppService.mainApp.unregister() + return + } + + if startOnLogin, SMAppService.mainApp.status != .enabled { + try SMAppService.mainApp.register() + } } } +#endif + + @objc private func handleUserDefaultsChanged(_ notification: Notification) { +#if os(macOS) + // This is idempotent + Task { do { try await updateAppService() } } +#endif + + // Update published properties + self.publishedInternetResourceEnabled = internetResourceEnabled + self.publishedInternetResourceForced = isInternetResourceForced + self.publishedHideAdminPortalMenuItem = hideAdminPortalMenuItem + + // Announce we changed + objectWillChange.send() + } +} + +// Configuration does not conform to Decodable, so introduce a simpler type here to encode for IPC +public struct TunnelConfiguration: Codable { + public let apiURL: String + public let accountSlug: String + public let logFilter: String + public let internetResourceEnabled: Bool + + public init(apiURL: String, accountSlug: String, logFilter: String, internetResourceEnabled: Bool) { + self.apiURL = apiURL + self.accountSlug = accountSlug + self.logFilter = logFilter + self.internetResourceEnabled = internetResourceEnabled + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift index 1f3540a17..1ba23795a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/ProviderMessage.swift @@ -9,8 +9,7 @@ import Foundation public enum ProviderMessage: Codable { case getResourceList(Data) - case getConfiguration(Data) - case setConfiguration(Configuration) + case setConfiguration(TunnelConfiguration) case signOut case clearLogs case getLogFolderSize @@ -24,7 +23,6 @@ public enum ProviderMessage: Codable { enum MessageType: String, Codable { case getResourceList - case getConfiguration case setConfiguration case signOut case clearLogs @@ -40,11 +38,8 @@ public enum ProviderMessage: Codable { 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) + let value = try container.decode(TunnelConfiguration.self, forKey: .value) self = .setConfiguration(value) case .signOut: self = .signOut @@ -65,9 +60,6 @@ public enum ProviderMessage: Codable { 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) 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 216ab6c5e..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Settings.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// 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 - @Published var startOnLogin: Bool - var isAuthURLOverridden = false - var isApiURLOverridden = false - var isLogFilterOverridden = false - var isAccountSlugOverridden = false - var isConnectOnStartOverridden = false - var isStartOnLoginOverridden = 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.startOnLogin = configuration.startOnLogin ?? Configuration.defaultStartOnLogin - - 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) - self.isStartOnLoginOverridden = configuration.isOverridden(Configuration.Keys.startOnLogin) - } - - func areAllFieldsOverridden() -> Bool { - return (isAuthURLOverridden && - isApiURLOverridden && - isLogFilterOverridden && - isAccountSlugOverridden && - isConnectOnStartOverridden && - isStartOnLoginOverridden) - } - - 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 && - startOnLogin == Configuration.defaultStartOnLogin) - } - - func isSaved() -> Bool { - return ( - authURL == configuration.authURL && - apiURL == configuration.apiURL && - logFilter == configuration.logFilter && - accountSlug == configuration.accountSlug && - connectOnStart == configuration.connectOnStart && - startOnLogin == configuration.startOnLogin) - } - - func reset() { - self.authURL = Configuration.defaultAuthURL - self.apiURL = Configuration.defaultApiURL - self.logFilter = Configuration.defaultLogFilter - self.accountSlug = Configuration.defaultAccountSlug - self.connectOnStart = Configuration.defaultConnectOnStart - self.startOnLogin = Configuration.defaultStartOnLogin - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift index f070b5869..94464b34a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift @@ -15,11 +15,11 @@ struct WebAuthSession { private static let scheme = "firezone-fd0020211111" static let anchor = PresentationAnchor() - static func signIn(store: Store) async throws { - let accountSlug = store.configuration?.accountSlug ?? "" + static func signIn(store: Store, configuration: Configuration? = nil) async throws { + let configuration = configuration ?? Configuration.shared - guard let authURL = URL(string: store.configuration?.authURL ?? Configuration.defaultAuthURL), - let authClient = try? AuthClient(authURL: authURL.appendingPathComponent(accountSlug)), + guard let authURL = URL(string: configuration.authURL), + let authClient = try? AuthClient(authURL: authURL.appendingPathComponent(configuration.accountSlug)), 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 ffe6b6955..d7614081f 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -16,18 +16,11 @@ 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 - // 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 @Published private(set) var vpnStatus: NEVPNStatus? @@ -43,14 +36,15 @@ public final class Store: ObservableObject { let sessionNotification = SessionNotification() - private var configurationTimer: Timer? - private var configurationUpdateTask: Task? private var resourcesTimer: Timer? private var resourceUpdateTask: Task? - + private var configuration: Configuration private var vpnConfigurationManager: VPNConfigurationManager? + private var cancellables: Set = [] + + public init(configuration: Configuration? = nil) { + self.configuration = configuration ?? Configuration.shared - public init() { // Load GUI-only cached state self.actorName = UserDefaults.standard.string(forKey: "actorName") ?? "Unknown user" @@ -60,6 +54,19 @@ public final class Store: ObservableObject { } } + // We monitor for any configuration changes and tell the tunnel service about them + self.configuration.objectWillChange + .receive(on: DispatchQueue.main) + .debounce(for: .seconds(0.3), scheduler: DispatchQueue.main) // These happen quite frequently + .sink(receiveValue: { [weak self] _ in + guard let self = self else { return } + + if self.vpnConfigurationManager != nil { + Task { do { try await self.ipcClient().setConfiguration(self.configuration) } catch { Log.error(error) } } + } + }) + .store(in: &cancellables) + // Load our state from the system. Based on what's loaded, we may need to ask the user for permission for things. // When everything loads correctly, we attempt to start the tunnel if connectOnStart is enabled. Task { @@ -68,7 +75,6 @@ public final class Store: ObservableObject { try await initSystemExtension() try await initVPNConfiguration() try await setupTunnelObservers() - try await initConfiguration() try await maybeAutoConnect() } catch { Log.error(error) @@ -155,23 +161,9 @@ public final class Store: ObservableObject { } } - // On macOS, after upgrading Firezone, we need to issue a startTunnel to start the IPC service so that we - // can fetch configuration. We try a few times here to do that so that we can determine connectOnStart, before - // giving up and polling configuration anyway. - private func initConfiguration() async throws { - let end = Date().addingTimeInterval(3) - - while configuration == nil && Date() < end { - _ = try await reloadConfigurationStartingSystemExtension() - try await Task.sleep(nanoseconds: 100_000_000) - } - - beginConfigurationPolling() - } - private func maybeAutoConnect() async throws { - if configuration?.connectOnStart == true { - try await manager().enableConfiguration() + if configuration.connectOnStart == true { + try await manager().enable() try ipcClient().start() } } @@ -179,8 +171,6 @@ public final class Store: ObservableObject { // Create a new VPN configuration in system settings. self.vpnConfigurationManager = try await VPNConfigurationManager() - self.configuration = try await ipcClient().getConfiguration() - try await setupTunnelObservers() } @@ -215,14 +205,17 @@ public final class Store: ObservableObject { let accountSlug = authResponse.accountSlug // This is only shown in the GUI, cache it here + self.actorName = actorName UserDefaults.standard.set(actorName, forKey: "actorName") + configuration.accountSlug = accountSlug Telemetry.accountSlug = accountSlug - try await manager().enableConfiguration() + try await manager().enable() + try await ipcClient().setConfiguration(configuration) // Bring the tunnel up and send it a token to start - try ipcClient().start(token: authResponse.token, accountSlug: accountSlug) + try ipcClient().start(token: authResponse.token) } func signOut() async throws { @@ -233,102 +226,8 @@ public final class Store: ObservableObject { try await ipcClient().clearLogs() } - func toggleInternetResource() async throws { - let enabled = configuration?.internetResourceEnabled == true - try await setInternetResourceEnabled(!enabled) - } - - // MARK: App configuration setters - - func applySettingsToConfiguration(_ settings: Settings) async throws { - configuration?.applySettings(settings) - try await setConfiguration(configuration) - } - - private func setInternetResourceEnabled(_ internetResourceEnabled: Bool) async throws { - configuration?.internetResourceEnabled = internetResourceEnabled - try await setConfiguration(configuration) - } - // MARK: Private functions - private func beginConfigurationPolling() { - // Ensure we're idempotent if called twice - if self.configurationTimer != nil { - return - } - - let updateConfiguration: @Sendable (Timer) -> Void = { _ in - Task { - await MainActor.run { - self.configurationUpdateTask?.cancel() - self.configurationUpdateTask = Task { - if !Task.isCancelled { - do { - _ = try await self.reloadConfigurationStartingSystemExtension() - } catch let error as NSError { - // https://developer.apple.com/documentation/networkextension/nevpnerror-swift.struct/code - if error.domain == "NEVPNErrorDomain" && error.code == 1 { - // not initialized yet - } else { - Log.error(error) - } - } catch { - Log.error(error) - } - } - } - } - } - } - - let intervalInSeconds: TimeInterval = 1 - let timer = Timer(timeInterval: intervalInSeconds, repeats: true, block: updateConfiguration) - - RunLoop.main.add(timer, forMode: .common) - self.configurationTimer = timer - } - - private func reloadConfigurationStartingSystemExtension() async throws -> Configuration? { - var configuration = try await ipcClient().getConfiguration() - -#if os(macOS) - if configuration == nil { - try ipcClient().startSystemExtension() - configuration = try await ipcClient().getConfiguration() - } -#endif - - self.configuration = configuration - - if Telemetry.firezoneId == nil { - Telemetry.firezoneId = configuration?.firezoneId - } - - try await updateAppService() - - return configuration - } - - // Register / unregister our launch service based on configuration. This is a major pain to do on macOS 12 and below, - // so this feature only enabled for macOS 13 and higher given the tiny Firezone installbase for macOS 12. - private func updateAppService() async throws { -#if os(macOS) - if #available(macOS 13.0, *) { - let startOnLogin = configuration?.startOnLogin ?? Configuration.defaultStartOnLogin - - if !startOnLogin, SMAppService.mainApp.status == .enabled { - try await SMAppService.mainApp.unregister() - return - } - - if startOnLogin, SMAppService.mainApp.status != .enabled { - try SMAppService.mainApp.register() - } - } -#endif - } - // Network Extensions don't have a 2-way binding up to the GUI process, // so we need to periodically ask the tunnel process for them. private func beginUpdatingResources() { @@ -381,15 +280,4 @@ 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/ViewModels/SettingsViewModel.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/ViewModels/SettingsViewModel.swift new file mode 100644 index 000000000..171a36f82 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/ViewModels/SettingsViewModel.swift @@ -0,0 +1,156 @@ +// +// SettingsViewModel.swift +// © 2025 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +import Combine +import Foundation +import SwiftUI + +@MainActor +class SettingsViewModel: ObservableObject { + private let configuration: Configuration + private var cancellables: Set = [] + + @Published private(set) var shouldDisableApplyButton = false + @Published private(set) var shouldDisableResetButton = false + @Published var authURL: String + @Published var apiURL: String + @Published var logFilter: String + @Published var accountSlug: String + @Published var connectOnStart: Bool + @Published var startOnLogin: Bool + + init(configuration: Configuration? = nil) { + self.configuration = configuration ?? Configuration.shared + + authURL = self.configuration.authURL + apiURL = self.configuration.apiURL + logFilter = self.configuration.logFilter + accountSlug = self.configuration.accountSlug + connectOnStart = self.configuration.connectOnStart + startOnLogin = self.configuration.startOnLogin + + Publishers.MergeMany( + $authURL, + $apiURL, + $logFilter, + $accountSlug + ) + .receive(on: RunLoop.main) + .sink(receiveValue: { [weak self] _ in + self?.updateDerivedState() + }) + .store(in: &cancellables) + + Publishers.MergeMany( + $connectOnStart, + $startOnLogin + ) + .receive(on: RunLoop.main) + .sink(receiveValue: { [weak self] _ in + self?.updateDerivedState() + }) + .store(in: &cancellables) + + updateDerivedState() + } + + func reset() { + authURL = Configuration.defaultAuthURL + apiURL = Configuration.defaultApiURL + logFilter = Configuration.defaultLogFilter + accountSlug = Configuration.defaultAccountSlug + connectOnStart = Configuration.defaultConnectOnStart + startOnLogin = Configuration.defaultStartOnLogin + + updateDerivedState() + } + + func save() async throws { + configuration.authURL = authURL + configuration.apiURL = apiURL + configuration.logFilter = logFilter + configuration.accountSlug = accountSlug + configuration.connectOnStart = connectOnStart + configuration.startOnLogin = startOnLogin + +#if os(macOS) + try await configuration.updateAppService() +#endif + + updateDerivedState() + } + + func isAllForced() -> Bool { + return ( + configuration.isAuthURLForced && + configuration.isApiURLForced && + configuration.isLogFilterForced && + configuration.isAccountSlugForced && + configuration.isConnectOnStartForced && + configuration.isStartOnLoginForced + ) + } + + 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 && + startOnLogin == Configuration.defaultStartOnLogin + ) + } + + func isSaved() -> Bool { + return ( + authURL == configuration.authURL && + apiURL == configuration.apiURL && + logFilter == configuration.logFilter && + accountSlug == configuration.accountSlug && + connectOnStart == configuration.connectOnStart && + startOnLogin == configuration.startOnLogin + ) + } + + private func updateDerivedState() { + shouldDisableApplyButton = ( + isAllForced() || + isSaved() || + !isValid() + ) + + shouldDisableResetButton = ( + isAllForced() || + isDefault() + ) + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 28503b3f4..3dd7b4799 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -24,6 +24,7 @@ public final class MenuBar: NSObject, ObservableObject { var lastShownFavorites: [Resource] = [] var lastShownOthers: [Resource] = [] var wasInternetResourceEnabled: Bool? + var wasInternetResourceForced: Bool? var cancellables: Set = [] var updateChecker: UpdateChecker var updateMenuDisplayed: Bool = false @@ -164,10 +165,13 @@ public final class MenuBar: NSObject, ObservableObject { return menuItem }() - public init(store: Store) { + private let configuration: Configuration + + public init(store: Store, configuration: Configuration? = nil) { + self.configuration = configuration ?? Configuration.shared statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) self.store = store - self.updateChecker = UpdateChecker(store: store) + self.updateChecker = UpdateChecker() self.signedOutIcon = NSImage(named: "MenuBarIconSignedOut") self.signedInConnectedIcon = NSImage(named: "MenuBarIconSignedInConnected") self.signedOutIconNotification = NSImage(named: "MenuBarIconSignedOutNotification") @@ -211,10 +215,25 @@ public final class MenuBar: NSObject, ObservableObject { self.handleStatusChanged() }).store(in: &cancellables) - store.$configuration + Publishers.CombineLatest( + configuration.$publishedInternetResourceEnabled, + configuration.$publishedInternetResourceForced + ) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] newEnabled, newForced in + guard let self = self else { return } + + if configuration.internetResourceEnabled != newEnabled + || configuration.isInternetResourceForced != newForced { + handleResourceListChanged() + } + }) + .store(in: &cancellables) + + configuration.$publishedHideAdminPortalMenuItem .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] _ in - self?.updateSignInMenuItems() + .sink(receiveValue: { [weak self] newValue in + self?.updateConfigurableMenuItems(hideAdminPortalMenuItem: newValue) }) .store(in: &cancellables) @@ -293,7 +312,8 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownFavorites = newFavorites - wasInternetResourceEnabled = store.configuration?.internetResourceEnabled + wasInternetResourceEnabled = configuration.internetResourceEnabled + wasInternetResourceForced = configuration.isInternetResourceForced } func populateOtherResourcesMenu(_ newOthers: [Resource]) { @@ -321,7 +341,8 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownOthers = newOthers - wasInternetResourceEnabled = store.configuration?.internetResourceEnabled + wasInternetResourceEnabled = configuration.internetResourceEnabled + wasInternetResourceForced = configuration.isInternetResourceForced } func updateStatusItemIcon() { @@ -366,11 +387,6 @@ 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() { @@ -417,6 +433,16 @@ public final class MenuBar: NSObject, ObservableObject { } } + func updateConfigurableMenuItems(hideAdminPortalMenuItem: Bool) { + if hideAdminPortalMenuItem { + adminPortalMenuItem.isEnabled = false + adminPortalMenuItem.isHidden = true + } else { + adminPortalMenuItem.isEnabled = true + adminPortalMenuItem.isHidden = false + } + } + // MARK: Menu object lifecycle helpers func createMenu() { @@ -435,9 +461,7 @@ public final class MenuBar: NSObject, ObservableObject { } menu.addItem(aboutMenuItem) - if !(store.configuration?.hideAdminPortalMenuItem ?? false) { - menu.addItem(adminPortalMenuItem) - } + menu.addItem(adminPortalMenuItem) menu.addItem(helpMenuItem) menu.addItem(settingsMenuItem) menu.addItem(NSMenuItem.separator()) @@ -482,7 +506,10 @@ public final class MenuBar: NSObject, ObservableObject { return false } - return wasInternetResourceEnabled != store.configuration?.internetResourceEnabled + return ( + wasInternetResourceEnabled != configuration.internetResourceEnabled || + wasInternetResourceForced != configuration.isInternetResourceForced + ) } func refreshUpdateItem() { @@ -518,7 +545,7 @@ public final class MenuBar: NSObject, ObservableObject { } func internetResourceTitle(resource: Resource) -> String { - let status = store.configuration?.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled + let status = configuration.internetResourceEnabled ? StatusSymbol.enabled : StatusSymbol.disabled return status + " " + resource.name } @@ -541,9 +568,9 @@ public final class MenuBar: NSObject, ObservableObject { } func internetResourceToggleTitle() -> String { - let isEnabled = store.configuration?.internetResourceEnabled == true + let isEnabled = configuration.internetResourceEnabled - if store.configuration?.isOverridden(Configuration.Keys.internetResourceEnabled) ?? false { + if configuration.isInternetResourceForced { return isEnabled ? "Managed: Enabled" : "Managed: Disabled" } @@ -642,7 +669,7 @@ public final class MenuBar: NSObject, ObservableObject { enableToggle.title = internetResourceToggleTitle() enableToggle.target = self - if store.configuration?.isOverridden(Configuration.Keys.internetResourceEnabled) ?? false { + if configuration.isInternetResourceForced { enableToggle.toolTip = "This setting is managed by your organization" enableToggle.isEnabled = false enableToggle.action = nil @@ -752,13 +779,13 @@ public final class MenuBar: NSObject, ObservableObject { } @objc func adminPortalButtonTapped() { - guard let baseURL = URL(string: store.configuration?.authURL ?? Configuration.defaultAuthURL) + guard let baseURL = URL(string: configuration.authURL) else { - Log.warning("admin portal URL invalid: \(String(describing: store.configuration?.authURL))") + Log.warning("Admin portal URL invalid: \(configuration.authURL)") return } - let accountSlug = store.configuration?.accountSlug ?? "" + let accountSlug = configuration.accountSlug let authURL = baseURL.appendingPathComponent(accountSlug) Task { await NSWorkspace.shared.openAsync(authURL) } @@ -799,15 +826,9 @@ public final class MenuBar: NSObject, ObservableObject { } @objc func internetResourceToggle(_ sender: NSMenuItem) { - Task { - do { - try await store.toggleInternetResource() - } catch { - Log.error(error) - } + configuration.internetResourceEnabled = !configuration.internetResourceEnabled - sender.title = internetResourceToggleTitle() - } + sender.title = internetResourceToggleTitle() } @objc func resourceURLTapped(_ sender: AnyObject?) { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift index 2a4cf2f35..e67d0d177 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine #if os(iOS) private func copyToClipboard(_ value: String) { @@ -230,39 +231,61 @@ struct InternetResourceHeader: View { } } +@MainActor +class ToggleInternetResourceButtonModel: ObservableObject { + private var cancellables: Set = [] + private let configuration = Configuration.shared + + @Published private(set) var enabled: Bool + @Published private(set) var forced: Bool + + init() { + self.enabled = configuration.internetResourceEnabled + self.forced = configuration.isInternetResourceForced + + Publishers.CombineLatest( + configuration.$publishedInternetResourceEnabled, + configuration.$publishedInternetResourceForced + ) + .receive(on: RunLoop.main) + .sink(receiveValue: { [self] enabled, forced in + self.enabled = enabled + self.forced = forced + }) + .store(in: &cancellables) + } + + func toggleInternetResource() { + configuration.internetResourceEnabled = !configuration.internetResourceEnabled + } + + func toggleResourceEnabledText() -> String { + if forced { + return enabled ? "Managed: Enabled" : "Managed: Disabled" + } + + return enabled ? "Disable this resource" : "Enable this resource" + } +} + struct ToggleInternetResourceButton: View { var resource: Resource @EnvironmentObject var store: Store - - private func toggleResourceEnabledText() -> String { - let isEnabled = store.configuration?.internetResourceEnabled ?? false - - if store.configuration?.isOverridden(Configuration.Keys.internetResourceEnabled) ?? false { - return isEnabled ? "Managed: Enabled" : "Managed: Disabled" - } - - return isEnabled ? "Disable this resource" : "Enable this resource" - } + @StateObject var viewModel: ToggleInternetResourceButtonModel = .init() var body: some View { Button( action: { - Task { - do { - try await store.toggleInternetResource() - } catch { - Log.error(error) - } - } + viewModel.toggleInternetResource() }, label: { HStack { - Text(toggleResourceEnabledText()) + Text(viewModel.toggleResourceEnabledText()) Spacer() } } ) - .disabled(store.configuration?.isOverridden(Configuration.Keys.internetResourceEnabled) ?? false) + .disabled(viewModel.forced) } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index e4e99f33d..3cd84dc4f 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.configuration?.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled + let status = Configuration.shared.internetResourceEnabled ? 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 5213539c5..545d15aff 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -73,88 +73,6 @@ extension FileManager { } } -@MainActor -class SettingsViewModel: ObservableObject { - private let store: Store - private var cancellables = Set() - - @Published var settings: Settings - - @Published private(set) var shouldDisableApplyButton = false - @Published private(set) var shouldDisableResetButton = false - - init(store: Store) { - self.store = store - - 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) - - Publishers.MergeMany([ - settings.$connectOnStart, - settings.$startOnLogin - ]) - .receive(on: RunLoop.main) - .sink(receiveValue: { [weak self] _ in - self?.updateDerivedState() - }) - .store(in: &cancellables) - } - - private func updateDerivedState() { - self.shouldDisableApplyButton = ( - settings.areAllFieldsOverridden() || - settings.isSaved() || - !settings.isValid() - ) - - self.shouldDisableResetButton = ( - settings.areAllFieldsOverridden() || - settings.isDefault() - ) - } - -} - // 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 { @@ -163,6 +81,7 @@ public struct SettingsView: View { @EnvironmentObject var errorHandler: GlobalErrorHandler private let store: Store + private let configuration: Configuration private enum ConfirmationAlertContinueAction: Int { case none @@ -196,7 +115,7 @@ public struct SettingsView: View { #endif private struct PlaceholderText { - static let authBaseURL = "Admin portal base URL" + static let authURL = "Admin portal auth URL" static let apiURL = "Control plane WebSocket URL" static let logFilter = "RUST_LOG-style filter string" static let accountSlug = "Account slug or ID (optional)" @@ -211,9 +130,10 @@ public struct SettingsView: View { ) } - public init(store: Store) { + public init(store: Store, configuration: Configuration? = nil) { self.store = store - _viewModel = StateObject(wrappedValue: SettingsViewModel(store: store)) + self.configuration = configuration ?? Configuration.shared + _viewModel = StateObject(wrappedValue: SettingsViewModel()) } public var body: some View { @@ -235,7 +155,7 @@ public struct SettingsView: View { Image(systemName: "gearshape.2") Text("Advanced") } - .badge(viewModel.settings.isValid() ? nil : "!") + .badge(viewModel.isValid() ? nil : "!") logsTab .tabItem { Image(systemName: "doc.text") @@ -264,19 +184,16 @@ public struct SettingsView: View { .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .alert( - "Saving settings will sign you out", + "Some settings may not have been applied", isPresented: $isShowingConfirmationAlert, presenting: confirmationAlertContinueAction, actions: { confirmationAlertContinueAction in - Button("Cancel", role: .cancel) { - // Nothing to do - } - Button("Continue") { + Button("OK") { withErrorHandler { try await confirmationAlertContinueAction.performAction(on: self) } } }, message: { _ in - Text("Changing settings will sign you out and disconnect you from resources") + Text("Some settings require signing out and in again before they take effect.") } ) } @@ -332,19 +249,16 @@ public struct SettingsView: View { Spacer() } .alert( - "Saving settings will sign you out", + "Some settings may not have been applied", isPresented: $isShowingConfirmationAlert, presenting: confirmationAlertContinueAction, actions: { confirmationAlertContinueAction in - Button("Cancel", role: .cancel) { - // Nothing to do - } - Button("Continue", role: .destructive) { + Button("OK", role: .destructive) { withErrorHandler { try await confirmationAlertContinueAction.performAction(on: self) } } }, message: { _ in - Text("Changing settings will sign you out and disconnect you from resources") + Text("Some settings require signing out and in again before they take effect.") } ) #else @@ -364,25 +278,25 @@ public struct SettingsView: View { .frame(width: 150, alignment: .trailing) TextField( "", - text: $viewModel.settings.accountSlug, + text: $viewModel.accountSlug, prompt: Text(PlaceholderText.accountSlug) ) - .disabled(viewModel.settings.isAccountSlugOverridden) + .disabled(configuration.isAccountSlugForced) .frame(width: 250) } .padding(.bottom, 10) - Toggle(isOn: $viewModel.settings.connectOnStart) { + Toggle(isOn: $viewModel.connectOnStart) { Text("Automatically connect when Firezone is launched") } .toggleStyle(.checkbox) - .disabled(viewModel.settings.isConnectOnStartOverridden) + .disabled(configuration.isConnectOnStartForced) - Toggle(isOn: $viewModel.settings.startOnLogin) { + Toggle(isOn: $viewModel.startOnLogin) { Text("Start Firezone when you sign into your Mac") } .toggleStyle(.checkbox) - .disabled(viewModel.settings.isStartOnLoginOverridden) + .disabled(configuration.isStartOnLoginForced) } .padding(10) Spacer() @@ -400,21 +314,21 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.accountSlug, - text: $viewModel.settings.accountSlug + text: $viewModel.accountSlug ) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) - .disabled(viewModel.settings.isAccountSlugOverridden) + .disabled(configuration.isAccountSlugForced) .padding(.bottom, 10) Spacer() - Toggle(isOn: $viewModel.settings.connectOnStart) { + Toggle(isOn: $viewModel.connectOnStart) { Text("Automatically connect when Firezone is launched") } .toggleStyle(.switch) - .disabled(viewModel.settings.isConnectOnStartOverridden) + .disabled(configuration.isConnectOnStartForced) } }, header: { Text("General Settings") }, @@ -450,10 +364,10 @@ public struct SettingsView: View { .frame(width: 150, alignment: .trailing) TextField( "", - text: $viewModel.settings.authURL, - prompt: Text(PlaceholderText.authBaseURL) + text: $viewModel.authURL, + prompt: Text(PlaceholderText.authURL) ) - .disabled(viewModel.settings.isAuthURLOverridden) + .disabled(configuration.isAuthURLForced) .frame(width: 250) } @@ -463,10 +377,10 @@ public struct SettingsView: View { .frame(width: 150, alignment: .trailing) TextField( "", - text: $viewModel.settings.apiURL, + text: $viewModel.apiURL, prompt: Text(PlaceholderText.apiURL) ) - .disabled(viewModel.settings.isApiURLOverridden) + .disabled(configuration.isApiURLForced) .frame(width: 250) } @@ -476,10 +390,10 @@ public struct SettingsView: View { .frame(width: 150, alignment: .trailing) TextField( "", - text: $viewModel.settings.logFilter, + text: $viewModel.logFilter, prompt: Text(PlaceholderText.logFilter) ) - .disabled(viewModel.settings.isLogFilterOverridden) + .disabled(configuration.isLogFilterForced) .frame(width: 250) } } @@ -499,13 +413,13 @@ public struct SettingsView: View { .foregroundStyle(.secondary) .font(.caption) TextField( - PlaceholderText.authBaseURL, - text: $viewModel.settings.authURL + PlaceholderText.authURL, + text: $viewModel.authURL ) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) - .disabled(viewModel.settings.isAuthURLOverridden) + .disabled(configuration.isAuthURLForced) } VStack(alignment: .leading, spacing: 2) { Text("API URL") @@ -513,12 +427,12 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.apiURL, - text: $viewModel.settings.apiURL + text: $viewModel.apiURL ) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) - .disabled(viewModel.settings.isApiURLOverridden) + .disabled(configuration.isApiURLForced) } VStack(alignment: .leading, spacing: 2) { Text("Log Filter") @@ -526,12 +440,12 @@ public struct SettingsView: View { .font(.caption) TextField( PlaceholderText.logFilter, - text: $viewModel.settings.logFilter + text: $viewModel.logFilter ) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) - .disabled(viewModel.settings.isLogFilterOverridden) + .disabled(configuration.isLogFilterForced) } HStack { Spacer() @@ -767,11 +681,6 @@ public struct SettingsView: View { private func saveSettings() async throws { try await viewModel.save() - - if [.connected, .connecting, .reasserting].contains(store.vpnStatus) { - // TODO: Warn user instead of signing out - try await self.store.signOut() - } } // Calculates the total size of our logs by summing the size of the diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift index 5a4b46561..be95d423c 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift @@ -4,7 +4,6 @@ // LICENSE: Apache-2.0 // -// Note: it should be easy to expand this module to iOS #if os(macOS) import Foundation import Combine @@ -28,13 +27,15 @@ class UpdateChecker { private let notificationAdapter: NotificationAdapter = NotificationAdapter() private let versionCheckUrl: URL private let marketingVersion: SemanticVersion - private let store: Store + private let configuration: Configuration private var cancellables: Set = [] @Published private(set) var updateAvailable: Bool = false - init(store: Store) { + init(configuration: Configuration? = nil) { + self.configuration = configuration ?? Configuration.shared + guard let versionCheckUrl = URL(string: "https://www.firezone.dev/api/releases"), let versionString = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, let marketingVersion = try? SemanticVersion(versionString) @@ -44,29 +45,7 @@ class UpdateChecker { self.versionCheckUrl = versionCheckUrl self.marketingVersion = marketingVersion - - self.store = store - - store.$configuration - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.handleConfigurationChange() - } - .store(in: &cancellables) - - handleConfigurationChange() - } - - private func handleConfigurationChange() { - let disabled = ( - store.configuration?.disableUpdateCheck ?? Configuration.defaultDisableUpdateCheck - ) || BundleHelper.isAppStore() - - if disabled { - stopCheckingForUpdates() - } else { - startCheckingForUpdates() - } + startCheckingForUpdates() } private func startCheckingForUpdates() { @@ -93,6 +72,10 @@ class UpdateChecker { } @objc private func checkForUpdates() { + if configuration.disableUpdateCheck { + return + } + let task = URLSession.shared.dataTask(with: versionCheckUrl) { [weak self] data, _, error in guard let self = self else { return } diff --git a/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift b/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift deleted file mode 100644 index 2e5f28fc6..000000000 --- a/swift/apple/FirezoneNetworkExtension/ConfigurationManager.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// 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 userDictKey = "dev.firezone.configuration" - let managedDictKey = "com.apple.configuration.managed" - - private var userDefaults: UserDefaults - - // We maintain a cache of the user dictionary to buffer against unnecessary reads from UserDefaults which - // can cause deadlocks in rare cases. - private var userDict: [String: Any?] - - private var managedDict: [String: Any?] - - private init() { - userDefaults = UserDefaults.standard - userDict = userDefaults.dictionary(forKey: userDictKey) ?? [:] - managedDict = userDefaults.dictionary(forKey: managedDictKey) ?? [:] - - migrateFirezoneId() - Telemetry.firezoneId = userDict[Configuration.Keys.firezoneId] as? String - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleUserDefaultsChanged), - name: UserDefaults.didChangeNotification, - object: userDefaults - ) - } - - deinit { - NotificationCenter.default.removeObserver(self, name: UserDefaults.didChangeNotification, object: userDefaults) - } - - // 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 - userDict[Configuration.Keys.startOnLogin] = configuration.startOnLogin - - saveUserDict() - } - - func toConfiguration() -> Configuration { - return Configuration(userDict: userDict, managedDict: managedDict) - } - - // Firezone ID migration. Can be removed once most clients migrate past 1.4.15. - private func migrateFirezoneId() { - - // 1. Try to load from file, deleting it - if let containerURL = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId), - let idFromFile = try? String(contentsOf: containerURL.appendingPathComponent("firezone-id")) { - setFirezoneId(idFromFile) - try? FileManager.default.removeItem(at: containerURL.appendingPathComponent("firezone-id")) - return - } - - // 2. Try to load from dict - if userDict[Configuration.Keys.firezoneId] is String { - return - } - - // 3. Generate and save new one - setFirezoneId(UUID().uuidString) - } - - @objc private func handleUserDefaultsChanged(_ notification: Notification) { - let newManagedDict = (userDefaults.dictionary(forKey: managedDictKey) ?? [:]) as [String: Any?] - - // NSDictionary conforms to Equatable - if (managedDict as NSDictionary) == (newManagedDict as NSDictionary) { - return - } - - Log.log("Applying MDM configuration. Old: \(managedDict) New: \(newManagedDict)") - self.managedDict = newManagedDict - } - - private func saveUserDict() { - userDefaults.set(userDict, forKey: userDictKey) - } - - private func setFirezoneId(_ firezoneId: String) { - userDict[Configuration.Keys.firezoneId] = firezoneId - saveUserDict() - } -} - -// Add methods needed by the tunnel side -extension Configuration { - func toDataIfChanged(hash: Data?) -> Data? { - let encoder = PropertyListEncoder() - - do { - let encoded = try encoder.encode(self) - 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 0edd92bb4..4b4f139fc 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -10,9 +10,7 @@ import System import os enum PacketTunnelProviderError: Error { - case apiURLIsInvalid - case logFilterIsInvalid - case accountSlugIsInvalid + case tunnelConfigurationIsInvalid case firezoneIdIsInvalid case tokenNotFoundInKeychain } @@ -26,15 +24,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } private var logExportState: LogExportState = .idle - private var configuration: Configuration + private var tunnelConfiguration: TunnelConfiguration? + private let defaults = UserDefaults.standard override init() { // Initialize Telemetry as early as possible Telemetry.start() - self.configuration = ConfigurationManager.shared.toConfiguration() - super.init() + + migrateFirezoneId() + self.tunnelConfiguration = TunnelConfiguration.tryLoad() } override func startTunnel( @@ -67,33 +67,25 @@ class PacketTunnelProvider: NEPacketTunnelProvider { do { try token.save() } catch { Log.error(error) } // The firezone id should be initialized by now - guard let id = configuration.firezoneId + guard let id = UserDefaults.standard.string(forKey: "firezoneId") else { throw PacketTunnelProviderError.firezoneIdIsInvalid } - // Now we should have a token, so continue connecting - let apiURL = legacyConfiguration?["apiURL"] ?? configuration.apiURL ?? Configuration.defaultApiURL - - // Reconfigure our Telemetry environment now that we know the API URL - Telemetry.setEnvironmentOrClose(apiURL) - - let logFilter = legacyConfiguration?["logFilter"] ?? configuration.logFilter ?? Configuration.defaultLogFilter - - // Prioritize passed accountSlug, updating saved account slug for next connect - guard let accountSlug = options?["accountSlug"] as? String ?? - legacyConfiguration?["accountSlug"] ?? - configuration.accountSlug + guard let apiURL = legacyConfiguration?["apiURL"] ?? tunnelConfiguration?.apiURL, + let logFilter = legacyConfiguration?["logFilter"] ?? tunnelConfiguration?.logFilter, + let accountSlug = legacyConfiguration?["accountSlug"] ?? tunnelConfiguration?.accountSlug else { - throw PacketTunnelProviderError.accountSlugIsInvalid + throw PacketTunnelProviderError.tunnelConfigurationIsInvalid } - configuration.accountSlug = accountSlug - ConfigurationManager.shared.setConfiguration(configuration) + + // Configure telemetry + Telemetry.setEnvironmentOrClose(apiURL) Telemetry.accountSlug = accountSlug let enabled = legacyConfiguration?["internetResourceEnabled"] let internetResourceEnabled = - enabled != nil ? enabled == "true" : (configuration.internetResourceEnabled ?? false) + enabled != nil ? enabled == "true" : (tunnelConfiguration?.internetResourceEnabled ?? false) let adapter = Adapter( apiURL: apiURL, @@ -164,20 +156,16 @@ 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 override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { do { let providerMessage = try PropertyListDecoder().decode(ProviderMessage.self, from: message) switch providerMessage { - case .getConfiguration(let hash): - let configurationPayload = configuration.toDataIfChanged(hash: hash) - completionHandler?(configurationPayload) - - case .setConfiguration(let configuration): - ConfigurationManager.shared.setConfiguration(configuration) - self.configuration = ConfigurationManager.shared.toConfiguration() + case .setConfiguration(let tunnelConfiguration): + tunnelConfiguration.save() + self.tunnelConfiguration = tunnelConfiguration + self.adapter?.setInternetResourceEnabled(tunnelConfiguration.internetResourceEnabled) completionHandler?(nil) case .signOut: @@ -319,4 +307,67 @@ class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler(data) } + + // Firezone ID migration. Can be removed once most clients migrate past 1.4.15. + private func migrateFirezoneId() { + let filename = "firezone-id" + let key = "firezoneId" + + // 1. Try to load from file, deleting it + if let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId), + let idFromFile = try? String(contentsOf: containerURL.appendingPathComponent(filename)) { + defaults.set(idFromFile, forKey: key) + try? FileManager.default.removeItem(at: containerURL.appendingPathComponent(filename)) + return + } + + // 2. Try to load from dict + if defaults.string(forKey: key) != nil { + return + } + + // 3. Generate and save new one + defaults.set(UUID().uuidString, forKey: key) + } +} + +// Increase usefulness of TunnelConfiguration now that we're over the IPC barrier +extension TunnelConfiguration { + func save() { + let key = "configurationCache" + + let dict: [String: Any] = [ + "apiURL": apiURL, + "logFilter": logFilter, + "accountSlug": accountSlug, + "internetResourceEnabled": internetResourceEnabled + ] + + UserDefaults.standard.set(dict, forKey: key) + } + + static func tryLoad() -> TunnelConfiguration? { + let key = "configurationCache" + + guard let dict = UserDefaults.standard.dictionary(forKey: key) + else { + return nil + } + + guard let apiURL = dict["apiURL"] as? String, + let logFilter = dict["logFilter"] as? String, + let accountSlug = dict["accountSlug"] as? String, + let internetResourceEnabled = dict["internetResourceEnabled"] as? Bool + else { + return nil + } + + return TunnelConfiguration( + apiURL: apiURL, + accountSlug: accountSlug, + logFilter: logFilter, + internetResourceEnabled: internetResourceEnabled + ) + } }