refactor(apple): Consolidate configuration to host app (#9196)

On Apple platforms, `UserDefaults` provides a convenient way to store
and fetch simple plist-compatible data for your app. Unbeknownst to the
author at the time of original implementation was the fact this these
keys are already designed for managed configurations to "mask" any
user-configured equivalents.

This means we no longer need to juggle two dicts in UserDefaults, and we
can instead check which keys are forced via a simple method call.

Additionally, the implementation was simplified in the following ways:

- The host app is the "source of truth" for app configuration now. The
tunnel service receives `setConfiguration` which applies the current
configuration, and saves it in order to start up again without the GUI
connected. The obvious caveat here is that if the GUI isn't running,
configuration such as `internetResourceEnabled` applied by the
administrator won't take effect. This is considered an edge case for the
time being since no customers have asked for this. Additionally, admins
can be advised to ensure the Firezone GUI is running on the system at
all times to prevent this.
- Settings and ConfigurationManager are now able to be removed - these
became redundant after consolidating configuration to the containing
app.
This commit is contained in:
Jamil
2025-05-22 09:18:00 -07:00
committed by GitHub
parent 100d5f2204
commit 6fd3493ed0
16 changed files with 585 additions and 762 deletions

View File

@@ -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 = "<group>"; };
8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemConfigurationResolvers.swift; sourceTree = "<group>"; };
8DA12C322BB7DA04007D91EB /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationManager.swift; sourceTree = "<group>"; };
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;
};

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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<String> = []
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<T>(
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
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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<Void, Never>?
private var resourcesTimer: Timer?
private var resourceUpdateTask: Task<Void, Never>?
private var configuration: Configuration
private var vpnConfigurationManager: VPNConfigurationManager?
private var cancellables: Set<AnyCancellable> = []
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
}
}

View File

@@ -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<AnyCancellable> = []
@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()
)
}
}

View File

@@ -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<AnyCancellable> = []
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?) {

View File

@@ -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<AnyCancellable> = []
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)
}
}

View File

@@ -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
}

View File

@@ -73,88 +73,6 @@ extension FileManager {
}
}
@MainActor
class SettingsViewModel: ObservableObject {
private let store: Store
private var cancellables = Set<AnyCancellable>()
@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

View File

@@ -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<AnyCancellable> = []
@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 }

View File

@@ -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
}
}

View File

@@ -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
)
}
}