refactor(apple): Add Settings model (#9155)

The settings fields are getting tedious to manage individually, so a
helper class `Settings` is added which abstracts all of the view-related
logic applicable to user-defined settings.

When settings are saved, they are first applied to the `store`'s
existing Configuration, and then that configuration is saved via a new
consolidated IPC call `setConfiguration`.

`actorName` has been moved to a GUI-only cached store since it does not
need to live on `Configuration` any longer.

This greatly simplifies both the view logic and the IPC interface.

Notably, this does not handle the edge case where the configuration is
updated while the Settings window is open. That is saved for a later
time.
This commit is contained in:
Jamil
2025-05-15 16:25:00 -07:00
committed by GitHub
parent 85ab395276
commit 7b4aa44f30
11 changed files with 298 additions and 390 deletions

View File

@@ -56,13 +56,17 @@ class IPCClient {
let encoder = PropertyListEncoder()
let decoder = PropertyListDecoder()
func start(token: String? = nil) throws {
var options: [String: NSObject] = [:]
// Auto-connect
func start() throws {
try session().startTunnel(options: nil)
}
// Pass token if provided
if let token = token {
options.merge(["token": token as NSObject]) { _, new in new }
}
// Sign in
func start(token: String, accountSlug: String) throws {
let options: [String: NSObject] = [
"token": token as NSObject,
"accountSlug": accountSlug as NSObject
]
try session().startTunnel(options: options)
}
@@ -105,32 +109,8 @@ class IPCClient {
}
}
func setAuthURL(_ authURL: String) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setAuthURL(authURL))
}
func setApiURL(_ apiURL: String) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setApiURL(apiURL))
}
func setLogFilter(_ logFilter: String) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setLogFilter(logFilter))
}
func setActorName(_ actorName: String) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setActorName(actorName))
}
func setAccountSlug(_ accountSlug: String) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setAccountSlug(accountSlug))
}
func setInternetResourceEnabled(_ enabled: Bool) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setInternetResourceEnabled(enabled))
}
func setConnectOnStart(_ connectOnStart: Bool) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setConnectOnStart(connectOnStart))
func setConfiguration(_ configuration: Configuration) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setConfiguration(configuration))
}
func fetchResources() async throws -> ResourceList {

View File

@@ -96,42 +96,46 @@ public class VPNConfigurationManager {
let ipcClient = IPCClient(session: session)
var userDict: [String: Any?] = [:]
var migrated = false
if let actorName = legacyConfiguration["actorName"] {
UserDefaults.standard.set(actorName, forKey: "actorName")
migrated = true
}
if let apiURL = legacyConfiguration["apiURL"] {
try await ipcClient.setApiURL(apiURL)
userDict[Configuration.Keys.apiURL] = apiURL
migrated = true
}
if let authURL = legacyConfiguration["authBaseURL"] {
try await ipcClient.setAuthURL(authURL)
migrated = true
}
if let actorName = legacyConfiguration["actorName"] {
try await ipcClient.setActorName(actorName)
userDict[Configuration.Keys.authURL] = authURL
migrated = true
}
if let accountSlug = legacyConfiguration["accountSlug"] {
try await ipcClient.setAccountSlug(accountSlug)
userDict[Configuration.Keys.accountSlug] = accountSlug
migrated = true
}
if let logFilter = legacyConfiguration["logFilter"],
!logFilter.isEmpty {
try await ipcClient.setLogFilter(logFilter)
userDict[Configuration.Keys.logFilter] = logFilter
migrated = true
}
if let internetResourceEnabled = legacyConfiguration["internetResourceEnabled"],
["false", "true"].contains(internetResourceEnabled) {
try await ipcClient.setInternetResourceEnabled(internetResourceEnabled == "true")
userDict[Configuration.Keys.internetResourceEnabled] = internetResourceEnabled == "true"
migrated = true
}
if !migrated { return }
let configuration = Configuration(userDict: userDict, managedDict: [:])
try await ipcClient.setConfiguration(configuration)
// Remove fields to prevent confusion if the user sees these in System Settings and wonders why they're stale.
if let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol {
protocolConfiguration.providerConfiguration = nil

View File

@@ -11,11 +11,13 @@ public class Configuration: Codable {
public static let defaultLogFilter = "info"
#endif
public static let defaultAccountSlug = ""
public static let defaultConnectOnStart = true
public struct Keys {
public static let authURL = "authURL"
public static let apiURL = "apiURL"
public static let logFilter = "logFilter"
public static let actorName = "actorName"
public static let accountSlug = "accountSlug"
public static let internetResourceEnabled = "internetResourceEnabled"
public static let firezoneId = "firezoneId"
@@ -24,7 +26,6 @@ public class Configuration: Codable {
}
public var authURL: String?
public var actorName: String?
public var firezoneId: String?
public var apiURL: String?
public var logFilter: String?
@@ -36,7 +37,6 @@ public class Configuration: Codable {
private var overriddenKeys: Set<String> = []
public init(userDict: [String: Any?], managedDict: [String: Any?]) {
self.actorName = userDict[Keys.actorName] as? String
self.firezoneId = userDict[Keys.firezoneId] as? String
setValue(forKey: Keys.authURL, from: managedDict, and: userDict) { [weak self] in self?.authURL = $0 }
@@ -58,6 +58,14 @@ public class Configuration: Codable {
return overriddenKeys.contains(key)
}
func applySettings(_ settings: Settings) {
self.authURL = settings.authURL
self.apiURL = settings.apiURL
self.logFilter = settings.logFilter
self.accountSlug = settings.accountSlug
self.connectOnStart = settings.connectOnStart
}
private func setValue<T>(
forKey key: String,
from managedDict: [String: Any?],

View File

@@ -7,20 +7,11 @@
import Foundation
// TODO: Can we simplify this / abstract it?
// swiftlint:disable cyclomatic_complexity
public enum ProviderMessage: Codable {
case getResourceList(Data)
case getConfiguration(Data)
case setConfiguration(Configuration)
case signOut
case setAuthURL(String)
case setApiURL(String)
case setLogFilter(String)
case setActorName(String)
case setAccountSlug(String)
case setInternetResourceEnabled(Bool)
case setConnectOnStart(Bool)
case clearLogs
case getLogFolderSize
case exportLogs
@@ -34,14 +25,8 @@ public enum ProviderMessage: Codable {
enum MessageType: String, Codable {
case getResourceList
case getConfiguration
case setConfiguration
case signOut
case setAuthURL
case setApiURL
case setLogFilter
case setActorName
case setAccountSlug
case setInternetResourceEnabled
case setConnectOnStart
case clearLogs
case getLogFolderSize
case exportLogs
@@ -52,33 +37,15 @@ public enum ProviderMessage: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(MessageType.self, forKey: .type)
switch type {
case .setAuthURL:
let value = try container.decode(String.self, forKey: .value)
self = .setAuthURL(value)
case .setApiURL:
let value = try container.decode(String.self, forKey: .value)
self = .setApiURL(value)
case .setLogFilter:
let value = try container.decode(String.self, forKey: .value)
self = .setLogFilter(value)
case .setActorName:
let value = try container.decode(String.self, forKey: .value)
self = .setActorName(value)
case .setAccountSlug:
let value = try container.decode(String.self, forKey: .value)
self = .setAccountSlug(value)
case .setInternetResourceEnabled:
let value = try container.decode(Bool.self, forKey: .value)
self = .setInternetResourceEnabled(value)
case .setConnectOnStart:
let value = try container.decode(Bool.self, forKey: .value)
self = .setConnectOnStart(value)
case .getResourceList:
let value = try container.decode(Data.self, forKey: .value)
self = .getResourceList(value)
case .getConfiguration:
let value = try container.decode(Data.self, forKey: .value)
self = .getConfiguration(value)
case .setConfiguration:
let value = try container.decode(Configuration.self, forKey: .value)
self = .setConfiguration(value)
case .signOut:
self = .signOut
case .clearLogs:
@@ -95,33 +62,15 @@ public enum ProviderMessage: Codable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .setAuthURL(let value):
try container.encode(MessageType.setAuthURL, forKey: .type)
try container.encode(value, forKey: .value)
case .setApiURL(let value):
try container.encode(MessageType.setApiURL, forKey: .type)
try container.encode(value, forKey: .value)
case .setLogFilter(let value):
try container.encode(MessageType.setLogFilter, forKey: .type)
try container.encode(value, forKey: .value)
case .setActorName(let value):
try container.encode(MessageType.setActorName, forKey: .type)
try container.encode(value, forKey: .value)
case .setAccountSlug(let value):
try container.encode(MessageType.setAccountSlug, forKey: .type)
try container.encode(value, forKey: .value)
case .setInternetResourceEnabled(let value):
try container.encode(MessageType.setInternetResourceEnabled, forKey: .type)
try container.encode(value, forKey: .value)
case .setConnectOnStart(let value):
try container.encode(MessageType.setConnectOnStart, forKey: .type)
try container.encode(value, forKey: .value)
case .getResourceList(let value):
try container.encode(MessageType.getResourceList, forKey: .type)
try container.encode(value, forKey: .value)
case .getConfiguration(let value):
try container.encode(MessageType.getConfiguration, forKey: .type)
try container.encode(value, forKey: .value)
case .setConfiguration(let value):
try container.encode(MessageType.setConfiguration, forKey: .type)
try container.encode(value, forKey: .value)
case .signOut:
try container.encode(MessageType.signOut, forKey: .type)
case .clearLogs:
@@ -135,5 +84,3 @@ public enum ProviderMessage: Codable {
}
}
}
// swiftlint:enable cyclomatic_complexity

View File

@@ -0,0 +1,97 @@
//
// Settings.swift
// © 2025 Firezone, Inc.
// LICENSE: Apache-2.0
//
// Settings represents the binding between our source-of-truth, Configuration, and user-configurable settings
// available in the SettingsView.
import Foundation
class Settings {
@Published var authURL: String
@Published var apiURL: String
@Published var logFilter: String
@Published var accountSlug: String
@Published var connectOnStart: Bool
var isAuthURLOverridden = false
var isApiURLOverridden = false
var isLogFilterOverridden = false
var isAccountSlugOverridden = false
var isConnectOnStartOverridden = false
private var configuration: Configuration
init(from configuration: Configuration) {
self.configuration = configuration
self.authURL = configuration.authURL ?? Configuration.defaultAuthURL
self.apiURL = configuration.apiURL ?? Configuration.defaultApiURL
self.logFilter = configuration.logFilter ?? Configuration.defaultLogFilter
self.accountSlug = configuration.accountSlug ?? Configuration.defaultAccountSlug
self.connectOnStart = configuration.connectOnStart ?? Configuration.defaultConnectOnStart
self.isAuthURLOverridden = configuration.isOverridden(Configuration.Keys.authURL)
self.isApiURLOverridden = configuration.isOverridden(Configuration.Keys.apiURL)
self.isLogFilterOverridden = configuration.isOverridden(Configuration.Keys.logFilter)
self.isAccountSlugOverridden = configuration.isOverridden(Configuration.Keys.accountSlug)
self.isConnectOnStartOverridden = configuration.isOverridden(Configuration.Keys.connectOnStart)
}
func areAllFieldsOverridden() -> Bool {
return (isAuthURLOverridden &&
isApiURLOverridden &&
isLogFilterOverridden &&
isAccountSlugOverridden &&
isConnectOnStartOverridden)
}
func isValid() -> Bool {
guard let apiURL = URL(string: apiURL),
apiURL.host != nil,
["wss", "ws"].contains(apiURL.scheme),
apiURL.pathComponents.isEmpty
else {
return false
}
guard let authURL = URL(string: authURL),
authURL.host != nil,
["http", "https"].contains(authURL.scheme),
authURL.pathComponents.isEmpty
else {
return false
}
guard !logFilter.isEmpty
else {
return false
}
return true
}
func isDefault() -> Bool {
return (authURL == Configuration.defaultAuthURL &&
apiURL == Configuration.defaultApiURL &&
logFilter == Configuration.defaultLogFilter &&
accountSlug == Configuration.defaultAccountSlug &&
connectOnStart == Configuration.defaultConnectOnStart)
}
func isSaved() -> Bool {
return (
authURL == configuration.authURL &&
apiURL == configuration.apiURL &&
logFilter == configuration.logFilter &&
accountSlug == configuration.accountSlug &&
connectOnStart == configuration.connectOnStart)
}
func reset() {
self.authURL = Configuration.defaultAuthURL
self.apiURL = Configuration.defaultApiURL
self.logFilter = Configuration.defaultLogFilter
self.accountSlug = Configuration.defaultAccountSlug
self.connectOnStart = Configuration.defaultConnectOnStart
}
}

View File

@@ -15,12 +15,15 @@ import AppKit
@MainActor
// TODO: Move some state logic to view models
// swiftlint:disable:next type_body_length
public final class Store: ObservableObject {
@Published private(set) var actorName: String
@Published private(set) var favorites = Favorites()
@Published private(set) var resourceList: ResourceList = .loading
// UserDefaults-backed app configuration that will publish updates to SwiftUI components
// User-configurable settings
@Published private(set) var settings: Settings?
// UserDefaults-backed app configuration
@Published private(set) var configuration: Configuration?
// Enacapsulate Tunnel status here to make it easier for other components to observe
@@ -46,6 +49,9 @@ public final class Store: ObservableObject {
private var vpnConfigurationManager: VPNConfigurationManager?
public init() {
// Load GUI-only cached state
self.actorName = UserDefaults.standard.string(forKey: "actorName") ?? "Unknown user"
self.sessionNotification.signInHandler = {
Task {
do { try await WebAuthSession.signIn(store: self) } catch { Log.error(error) }
@@ -84,10 +90,10 @@ public final class Store: ObservableObject {
try await manager.maybeMigrateConfiguration()
self.vpnConfigurationManager = manager
try await setupTunnelObservers()
try await manager.enableConfiguration()
self.configuration = try await ipcClient().getConfiguration()
Telemetry.firezoneId = configuration?.firezoneId
try await manager.enableConfiguration()
if configuration?.connectOnStart ?? true {
try ipcClient().start()
}
@@ -221,17 +227,18 @@ public final class Store: ObservableObject {
}
func signIn(authResponse: AuthResponse) async throws {
// Save actorName
try await setActorName(authResponse.actorName)
let actorName = authResponse.actorName
let accountSlug = authResponse.accountSlug
// This will save the account slug even if overridden from MDM. This is what we want - if the admin removes the
// override, this will take effect again.
try await setAccountSlug(authResponse.accountSlug)
// This is only shown in the GUI, cache it here
UserDefaults.standard.set(actorName, forKey: "actorName")
Telemetry.accountSlug = accountSlug
try await manager().enableConfiguration()
// Bring the tunnel up and send it a token to start
try ipcClient().start(token: authResponse.token)
try ipcClient().start(token: authResponse.token, accountSlug: accountSlug)
}
func signOut() async throws {
@@ -249,53 +256,18 @@ public final class Store: ObservableObject {
// MARK: App configuration setters
func setActorName(_ actorName: String) async throws {
try await ipcClient().setActorName(actorName)
configuration?.actorName = actorName
func applySettingsToConfiguration(_ settings: Settings) async throws {
configuration?.applySettings(settings)
try await setConfiguration(configuration)
}
func setAccountSlug(_ accountSlug: String) async throws {
try await ipcClient().setAccountSlug(accountSlug)
configuration?.accountSlug = accountSlug
// Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure.
Telemetry.accountSlug = accountSlug
}
func setAuthURL(_ authURL: String) async throws {
try await ipcClient().setAuthURL(authURL)
configuration?.authURL = authURL
}
func setApiURL(_ apiURL: String) async throws {
try await ipcClient().setApiURL(apiURL)
configuration?.apiURL = apiURL
// Reconfigure our Telemetry environment in case it changed
Telemetry.setEnvironmentOrClose(apiURL)
}
func setLogFilter(_ logFilter: String) async throws {
try await ipcClient().setLogFilter(logFilter)
configuration?.logFilter = logFilter
}
func setInternetResourceEnabled(_ internetResourceEnabled: Bool) async throws {
try await ipcClient().setInternetResourceEnabled(internetResourceEnabled)
private func setInternetResourceEnabled(_ internetResourceEnabled: Bool) async throws {
configuration?.internetResourceEnabled = internetResourceEnabled
}
func setConnectOnStart(_ connectOnStart: Bool) async throws {
try await ipcClient().setConnectOnStart(connectOnStart)
configuration?.connectOnStart = connectOnStart
try await setConfiguration(configuration)
}
// MARK: Private functions
private func start(token: String? = nil) throws {
try ipcClient().start(token: token)
}
private func beginConfigurationPolling() {
// Ensure we're idempotent if called twice
if self.configurationTimer != nil {
@@ -378,4 +350,15 @@ public final class Store: ObservableObject {
resourcesTimer = nil
resourceList = ResourceList.loading
}
private func setConfiguration(_ configuration: Configuration?) async throws {
guard let configuration = configuration
else {
Log.warning("Tried to set configuration before it was initialized")
return
}
try await ipcClient().setConfiguration(configuration)
self.configuration = configuration
}
}

View File

@@ -210,6 +210,13 @@ public final class MenuBar: NSObject, ObservableObject {
self.handleStatusChanged()
}).store(in: &cancellables)
store.$configuration
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] _ in
self?.updateSignInMenuItems()
})
.store(in: &cancellables)
updateChecker.$updateAvailable
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] _ in
@@ -350,7 +357,7 @@ public final class MenuBar: NSObject, ObservableObject {
signOutMenuItem.isHidden = true
settingsMenuItem.target = self
case .connected, .reasserting, .connecting:
let title = "Signed in as \(store.configuration?.actorName ?? "Unknown User")"
let title = "Signed in as \(store.actorName)"
signInMenuItem.title = title
signInMenuItem.target = nil
signOutMenuItem.isHidden = false
@@ -358,6 +365,11 @@ public final class MenuBar: NSObject, ObservableObject {
@unknown default:
break
}
// Configuration must be initialized to manage settings
if store.configuration == nil {
settingsMenuItem.target = nil
}
}
func updateResourcesMenuItems() {

View File

@@ -13,11 +13,20 @@ import SwiftUI
enum SettingsViewError: Error {
case logFolderIsUnavailable
case configurationNotInitialized
var localizedDescription: String {
switch self {
case .logFolderIsUnavailable:
return "Log folder is unavailable."
return """
Log folder is unavailable.
Try restarting your device or reinstalling Firezone if this issue persists.
"""
case .configurationNotInitialized:
return """
Configuration is not initialized.
Try restarting your device or reinstalling Firezone if this issue persists.
"""
}
}
}
@@ -69,153 +78,78 @@ class SettingsViewModel: ObservableObject {
private let store: Store
private var cancellables = Set<AnyCancellable>()
@Published var authURL: String
@Published var apiURL: String
@Published var logFilter: String
@Published var accountSlug: String
@Published var connectOnStart: Bool
@Published private(set) var isAuthURLOverridden = false
@Published private(set) var isApiURLOverridden = false
@Published private(set) var isLogFilterOverridden = false
@Published private(set) var isAccountSlugOverridden = false
@Published private(set) var isConnectOnStartOverridden = false
@Published var settings: Settings
@Published private(set) var shouldDisableApplyButton = false
@Published private(set) var shouldDisableResetButton = false
@Published private(set) var areSettingsDefault = true
@Published private(set) var areSettingsValid = true
@Published private(set) var areSettingsSaved = true
init(store: Store) {
self.store = store
self.authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL
self.apiURL = store.configuration?.apiURL ?? Configuration.defaultApiURL
self.logFilter = store.configuration?.logFilter ?? Configuration.defaultLogFilter
self.accountSlug = store.configuration?.accountSlug ?? ""
self.connectOnStart = store.configuration?.connectOnStart ?? true
updateDerivedState()
// Update our state from our text fields
Publishers.CombineLatest(
Publishers.CombineLatest4($authURL, $apiURL, $logFilter, $accountSlug),
$connectOnStart
)
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.updateDerivedState()
guard let configuration = store.configuration
else {
fatalError("Configuration must be initialized to view settings")
}
self.settings = Settings(from: configuration)
setupObservers()
// TODO: Handle updates to configuration while the SettingsView is open?
}
func reset() {
settings.reset()
objectWillChange.send()
updateDerivedState()
}
func save() async throws {
try await store.applySettingsToConfiguration(settings)
guard let configuration = store.configuration
else {
throw SettingsViewError.configurationNotInitialized
}
self.settings = Settings(from: configuration)
setupObservers()
}
private func setupObservers() {
// These all need to be the same underlying type
Publishers.MergeMany([
settings.$authURL,
settings.$apiURL,
settings.$accountSlug,
settings.$logFilter
])
.receive(on: RunLoop.main)
.sink(receiveValue: { [weak self] _ in
self?.updateDerivedState()
})
.store(in: &cancellables)
// Update our state from configuration updates
store.$configuration
settings.$connectOnStart
.receive(on: RunLoop.main)
.sink { [weak self] newConfiguration in
self?.isAuthURLOverridden = newConfiguration?.isOverridden(Configuration.Keys.authURL) ?? false
self?.isApiURLOverridden = newConfiguration?.isOverridden(Configuration.Keys.apiURL) ?? false
self?.isLogFilterOverridden = newConfiguration?.isOverridden(Configuration.Keys.logFilter) ?? false
self?.isAccountSlugOverridden = newConfiguration?.isOverridden(Configuration.Keys.accountSlug) ?? false
self?.isConnectOnStartOverridden = newConfiguration?.isOverridden(Configuration.Keys.connectOnStart) ?? false
.sink(receiveValue: { [weak self] _ in
self?.updateDerivedState()
}
})
.store(in: &cancellables)
}
private func isAuthURLValid() -> Bool {
if let authURL = URL(string: authURL),
authURL.host != nil,
["https", "http"].contains(authURL.scheme),
// Be restrictive - account slug will be appended
authURL.pathComponents.isEmpty {
return true
}
return false
}
private func isApiURLValid() -> Bool {
if let apiURL = URL(string: apiURL),
apiURL.host != nil,
["wss", "ws"].contains(apiURL.scheme),
// Be restrictive - account slug will be appended
apiURL.pathComponents.isEmpty {
return true
}
return false
}
private func isLogFilterValid() -> Bool {
return !logFilter.isEmpty
}
private func isAccountSlugValid() -> Bool {
// URL automatically percent-encodes
return true
}
private func updateDerivedState() {
self.areSettingsSaved = (self.authURL == store.configuration?.authURL &&
self.apiURL == store.configuration?.apiURL &&
self.logFilter == store.configuration?.logFilter &&
self.accountSlug == store.configuration?.accountSlug &&
self.connectOnStart == store.configuration?.connectOnStart)
self.areSettingsValid = isAuthURLValid() && isApiURLValid() && isLogFilterValid() && isAccountSlugValid()
self.areSettingsDefault = (self.authURL == Configuration.defaultAuthURL &&
self.apiURL == Configuration.defaultApiURL &&
self.logFilter == Configuration.defaultLogFilter &&
self.accountSlug == "" &&
self.connectOnStart == true)
self.shouldDisableApplyButton = (
isAuthURLOverridden &&
isApiURLOverridden &&
isLogFilterOverridden &&
isAccountSlugOverridden &&
isConnectOnStartOverridden
) || areSettingsSaved || !areSettingsValid
settings.areAllFieldsOverridden() ||
settings.isSaved() ||
!settings.isValid()
)
self.shouldDisableResetButton = (
isAuthURLOverridden &&
isApiURLOverridden &&
isLogFilterOverridden &&
isAccountSlugOverridden &&
isConnectOnStartOverridden
) || areSettingsDefault
settings.areAllFieldsOverridden() ||
settings.isDefault()
)
}
func applySettingsToStore() async throws {
try await store.setApiURL(apiURL)
try await store.setLogFilter(logFilter)
try await store.setAuthURL(authURL)
try await store.setAccountSlug(accountSlug)
try await store.setConnectOnStart(connectOnStart)
updateDerivedState()
}
func revertToDefaultSettings() {
self.authURL = Configuration.defaultAuthURL
self.apiURL = Configuration.defaultApiURL
self.logFilter = Configuration.defaultLogFilter
self.accountSlug = ""
self.connectOnStart = true
}
func reloadSettingsFromStore() {
self.authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL
self.apiURL = store.configuration?.apiURL ?? Configuration.defaultApiURL
self.logFilter = store.configuration?.logFilter ?? Configuration.defaultLogFilter
self.accountSlug = store.configuration?.accountSlug ?? ""
self.connectOnStart = store.configuration?.connectOnStart ?? true
}
}
// TODO: Move business logic to ViewModel to remove dependency on Store and fix body length
@@ -298,7 +232,7 @@ public struct SettingsView: View {
Image(systemName: "gearshape.2")
Text("Advanced")
}
.badge(viewModel.areSettingsValid ? nil : "!")
.badge(viewModel.settings.isValid() ? nil : "!")
logsTab
.tabItem {
Image(systemName: "doc.text")
@@ -321,9 +255,7 @@ public struct SettingsView: View {
.disabled(viewModel.shouldDisableApplyButton)
}
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
self.reloadSettings()
}
Button("Cancel") { dismiss() }
}
}
.navigationTitle("Settings")
@@ -372,7 +304,7 @@ public struct SettingsView: View {
Button(
"Reset to Defaults",
action: {
viewModel.revertToDefaultSettings()
viewModel.reset()
}
)
.disabled(viewModel.shouldDisableResetButton)
@@ -412,7 +344,6 @@ public struct SettingsView: View {
Text("Changing settings will sign you out and disconnect you from resources")
}
)
.onDisappear(perform: { self.reloadSettings() })
#else
#error("Unsupported platform")
#endif
@@ -430,18 +361,18 @@ public struct SettingsView: View {
.frame(width: 150, alignment: .trailing)
TextField(
"",
text: $viewModel.accountSlug,
text: $viewModel.settings.accountSlug,
prompt: Text(PlaceholderText.accountSlug)
)
.disabled(viewModel.isAccountSlugOverridden)
.disabled(viewModel.settings.isAccountSlugOverridden)
.frame(width: 250)
}
.padding(.bottom, 10)
Toggle(isOn: $viewModel.connectOnStart) {
Toggle(isOn: $viewModel.settings.connectOnStart) {
Text("Connect on launch")
}
.toggleStyle(.checkbox)
.disabled(viewModel.isConnectOnStartOverridden)
.disabled(viewModel.settings.isConnectOnStartOverridden)
}
.padding(10)
Spacer()
@@ -459,21 +390,21 @@ public struct SettingsView: View {
.font(.caption)
TextField(
PlaceholderText.accountSlug,
text: $viewModel.accountSlug
text: $viewModel.settings.accountSlug
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.submitLabel(.done)
.disabled(viewModel.isAccountSlugOverridden)
.disabled(viewModel.settings.isAccountSlugOverridden)
.padding(.bottom, 10)
Spacer()
Toggle(isOn: $viewModel.connectOnStart) {
Toggle(isOn: $viewModel.settings.connectOnStart) {
Text("Connect on launch")
}
.toggleStyle(.switch)
.disabled(viewModel.isConnectOnStartOverridden)
.disabled(viewModel.settings.isConnectOnStartOverridden)
}
},
header: { Text("General Settings") },
@@ -509,10 +440,10 @@ public struct SettingsView: View {
.frame(width: 150, alignment: .trailing)
TextField(
"",
text: $viewModel.authURL,
text: $viewModel.settings.authURL,
prompt: Text(PlaceholderText.authBaseURL)
)
.disabled(viewModel.isAuthURLOverridden)
.disabled(viewModel.settings.isAuthURLOverridden)
.frame(width: 250)
}
@@ -522,10 +453,10 @@ public struct SettingsView: View {
.frame(width: 150, alignment: .trailing)
TextField(
"",
text: $viewModel.apiURL,
text: $viewModel.settings.apiURL,
prompt: Text(PlaceholderText.apiURL)
)
.disabled(viewModel.isApiURLOverridden)
.disabled(viewModel.settings.isApiURLOverridden)
.frame(width: 250)
}
@@ -535,10 +466,10 @@ public struct SettingsView: View {
.frame(width: 150, alignment: .trailing)
TextField(
"",
text: $viewModel.logFilter,
text: $viewModel.settings.logFilter,
prompt: Text(PlaceholderText.logFilter)
)
.disabled(viewModel.isLogFilterOverridden)
.disabled(viewModel.settings.isLogFilterOverridden)
.frame(width: 250)
}
}
@@ -559,12 +490,12 @@ public struct SettingsView: View {
.font(.caption)
TextField(
PlaceholderText.authBaseURL,
text: $viewModel.authURL
text: $viewModel.settings.authURL
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.submitLabel(.done)
.disabled(viewModel.isAuthURLOverridden)
.disabled(viewModel.settings.isAuthURLOverridden)
}
VStack(alignment: .leading, spacing: 2) {
Text("API URL")
@@ -572,12 +503,12 @@ public struct SettingsView: View {
.font(.caption)
TextField(
PlaceholderText.apiURL,
text: $viewModel.apiURL
text: $viewModel.settings.apiURL
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.submitLabel(.done)
.disabled(viewModel.isApiURLOverridden)
.disabled(viewModel.settings.isApiURLOverridden)
}
VStack(alignment: .leading, spacing: 2) {
Text("Log Filter")
@@ -585,19 +516,19 @@ public struct SettingsView: View {
.font(.caption)
TextField(
PlaceholderText.logFilter,
text: $viewModel.logFilter
text: $viewModel.settings.logFilter
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.submitLabel(.done)
.disabled(viewModel.isLogFilterOverridden)
.disabled(viewModel.settings.isLogFilterOverridden)
}
HStack {
Spacer()
Button(
"Reset to Defaults",
action: {
viewModel.revertToDefaultSettings()
viewModel.reset()
}
)
.disabled(viewModel.shouldDisableResetButton)
@@ -734,11 +665,6 @@ public struct SettingsView: View {
dismiss()
}
private func reloadSettings() {
viewModel.reloadSettingsFromStore()
dismiss()
}
#if os(macOS)
private func exportLogsWithSavePanelOnMac() {
self.isExportingLogs = true
@@ -830,7 +756,7 @@ public struct SettingsView: View {
}
private func saveSettings() async throws {
try await viewModel.applySettingsToStore()
try await viewModel.save()
if [.connected, .connecting, .reasserting].contains(store.status) {
// TODO: Warn user instead of signing out

View File

@@ -60,7 +60,7 @@ struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_n
private var authMenu: some View {
Menu {
if store.status == .connected {
Text("Signed in as \(store.configuration?.actorName ?? "Unknown user")")
Text("Signed in as \(store.actorName)")
Button(
action: {
signOutButtonTapped()

View File

@@ -19,9 +19,9 @@ class ConfigurationManager {
// We maintain a cache of the user dictionary to buffer against unnecessary reads from UserDefaults which
// can cause deadlocks in rare cases.
var userDict: [String: Any?]
private var userDict: [String: Any?]
var managedDict: [String: Any?] {
private var managedDict: [String: Any?] {
userDefaults.dictionary(forKey: managedDictKey) ?? [:]
}
@@ -33,39 +33,19 @@ class ConfigurationManager {
Telemetry.firezoneId = userDict[Configuration.Keys.firezoneId] as? String
}
func setAuthURL(_ authURL: String) {
userDict[Configuration.Keys.authURL] = authURL
// Save user-settable configuration
func setConfiguration(_ configuration: Configuration) {
userDict[Configuration.Keys.authURL] = configuration.authURL
userDict[Configuration.Keys.apiURL] = configuration.apiURL
userDict[Configuration.Keys.logFilter] = configuration.logFilter
userDict[Configuration.Keys.accountSlug] = configuration.accountSlug
userDict[Configuration.Keys.connectOnStart] = configuration.connectOnStart
saveUserDict()
}
func setApiURL(_ apiURL: String) {
userDict[Configuration.Keys.apiURL] = apiURL
saveUserDict()
}
func setLogFilter(_ logFilter: String) {
userDict[Configuration.Keys.logFilter] = logFilter
saveUserDict()
}
func setActorName(_ actorName: String) {
userDict[Configuration.Keys.actorName] = actorName
saveUserDict()
}
func setAccountSlug(_ accountSlug: String) {
userDict[Configuration.Keys.accountSlug] = accountSlug
saveUserDict()
}
func setInternetResourceEnabled(_ internetResourceEnabled: Bool) {
userDict[Configuration.Keys.internetResourceEnabled] = internetResourceEnabled
saveUserDict()
}
func setConnectOnStart(_ connectOnStart: Bool) {
userDict[Configuration.Keys.connectOnStart] = connectOnStart
saveUserDict()
func toConfiguration() -> Configuration {
return Configuration(userDict: userDict, managedDict: managedDict)
}
// Firezone ID migration. Can be removed once most clients migrate past 1.4.15.

View File

@@ -32,10 +32,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
// Initialize Telemetry as early as possible
Telemetry.start()
self.configuration = Configuration(
userDict: ConfigurationManager.shared.userDict,
managedDict: ConfigurationManager.shared.managedDict
)
self.configuration = ConfigurationManager.shared.toConfiguration()
super.init()
}
@@ -78,10 +75,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
let logFilter = legacyConfiguration?["logFilter"] ?? configuration.logFilter ?? Configuration.defaultLogFilter
guard let accountSlug = legacyConfiguration?["accountSlug"] ?? configuration.accountSlug
// Prioritize passed accountSlug, updating saved account slug for next connect
guard let accountSlug = options?["accountSlug"] as? String ??
legacyConfiguration?["accountSlug"] ??
configuration.accountSlug
else {
throw PacketTunnelProviderError.accountSlugIsInvalid
}
configuration.accountSlug = accountSlug
ConfigurationManager.shared.setConfiguration(configuration)
Telemetry.accountSlug = accountSlug
let enabled = legacyConfiguration?["internetResourceEnabled"]
@@ -157,7 +159,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
// It would be helpful to be able to encapsulate Errors here. To do that
// we need to update ProviderMessage to encode/decode Result to and from Data.
// TODO: Move to a more abstract IPC protocol
// swiftlint:disable:next cyclomatic_complexity function_body_length
// swiftlint:disable:next cyclomatic_complexity
override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) {
do {
let providerMessage = try PropertyListDecoder().decode(ProviderMessage.self, from: message)
@@ -168,40 +170,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
let configurationPayload = configuration.toDataIfChanged(hash: hash)
completionHandler?(configurationPayload)
case .setAuthURL(let authURL):
configuration.authURL = authURL
ConfigurationManager.shared.setAuthURL(authURL)
completionHandler?(nil)
case .setApiURL(let apiURL):
configuration.apiURL = apiURL
ConfigurationManager.shared.setApiURL(apiURL)
completionHandler?(nil)
case .setActorName(let actorName):
configuration.actorName = actorName
ConfigurationManager.shared.setActorName(actorName)
completionHandler?(nil)
case .setAccountSlug(let accountSlug):
configuration.accountSlug = accountSlug
ConfigurationManager.shared.setAccountSlug(accountSlug)
completionHandler?(nil)
case .setLogFilter(let logFilter):
configuration.logFilter = logFilter
ConfigurationManager.shared.setLogFilter(logFilter)
completionHandler?(nil)
case .setInternetResourceEnabled(let enabled):
configuration.internetResourceEnabled = enabled
ConfigurationManager.shared.setInternetResourceEnabled(enabled)
adapter?.setInternetResourceEnabled(enabled)
completionHandler?(nil)
case .setConnectOnStart(let connectOnStart):
configuration.connectOnStart = connectOnStart
ConfigurationManager.shared.setConnectOnStart(connectOnStart)
case .setConfiguration(let configuration):
self.configuration = configuration
ConfigurationManager.shared.setConfiguration(configuration)
completionHandler?(nil)
case .signOut: