mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user