refactor(apple): Consolidate app configuration to UserDefaults (#9056)

We are currently storing app configuration across three places:

- UserDefaults (favorite resources)
- VPN configuration (Settings)
- Disk (firezone id)

These can be consolidated to UserDefaults, which is the standard way to
store app configuration like this.

UserDefaults is the umbrella persistence store for regular app
configuration (`plist` files which are just XML dictionaries),
iCloud-synced app configuration across a user's devices, and managed app
configuration (MDM). They provide a cached, thread-safe, and
interprocess-supported mechanism for handling app configuration. We can
also subscribe to changes on this app configuration to react to changes.

Unfortunately, the System Extension ruins some of our fun because it
runs as root, and is confined to a different group container, meaning we
cannot share configuration directly between GUI and tunnel procs.

To address this, we use the tunnel process to store all vital
configuration and introduce IPC calls to set and fetch these.

Commit-by-commit review recommended, but things got a little crazy
towards the end when I realized that we can't share a single
UserDefaults between both procs.
This commit is contained in:
Jamil
2025-05-11 22:27:49 -07:00
committed by GitHub
parent 575e974547
commit 1ceccc0da0
23 changed files with 679 additions and 512 deletions

View File

@@ -32,6 +32,8 @@
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, ); }; };
@@ -108,6 +110,7 @@
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; };
@@ -171,6 +174,7 @@
05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */,
6FE454EA2A5BFABA006549B1 /* Adapter.swift */,
6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */,
8DA9BFD02DCFB8C5008E7E25 /* ConfigurationManager.swift */,
6FE455082A5D110D006549B1 /* CallbackHandler.swift */,
6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */,
8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */,
@@ -334,7 +338,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1610;
LastUpgradeCheck = 1620;
LastUpgradeCheck = 1630;
TargetAttributes = {
05CF1CEF290B1CEE00CF4755 = {
CreatedOnToolsVersion = 14.0.1;
@@ -492,6 +496,7 @@
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 */,
@@ -512,6 +517,7 @@
8D5048002CE6AA60009802E9 /* SystemConfigurationResolvers.swift in Sources */,
8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */,
8D5047FF2CE6AA54009802E9 /* Adapter.swift in Sources */,
8DA9BFD22DCFB8C5008E7E25 /* ConfigurationManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -739,6 +745,7 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 47R2M6779T;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -807,6 +814,7 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 47R2M6779T;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
LastUpgradeVersion = "1630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -57,8 +57,7 @@ struct FirezoneApp: App {
"Settings",
id: AppView.WindowDefinition.settings.identifier
) {
SettingsView()
.environmentObject(store)
SettingsView(store: store)
}
.handlesExternalEvents(
matching: [AppView.WindowDefinition.settings.externalEventMatchString]

View File

@@ -6,7 +6,7 @@
import Foundation
enum BundleHelper {
public enum BundleHelper {
static func isAppStore() -> Bool {
if let receiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: receiptURL.path) {
@@ -24,7 +24,7 @@ enum BundleHelper {
return String(gitSha.prefix(8))
}
static var appGroupId: String {
public static var appGroupId: String {
guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String
else {
fatalError("AppGroupIdentifier missing in app's Info.plist")

View File

@@ -8,6 +8,9 @@ import CryptoKit
import Foundation
import NetworkExtension
// TODO: Use a more abstract IPC protocol to make this less terse
// TODO: Consider making this an actor to guarantee strict ordering
class IPCClient {
enum Error: Swift.Error {
case invalidNotification
@@ -41,17 +44,17 @@ 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
}
// Encoder used to send messages to the tunnel
let encoder = {
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
return encoder
}()
let encoder = PropertyListEncoder()
let decoder = PropertyListDecoder()
func start(token: String? = nil) throws {
var options: [String: NSObject] = [:]
@@ -61,27 +64,69 @@ class IPCClient {
options.merge(["token": token as NSObject]) { _, new in new }
}
// Pass pre-1.4.0 Firezone ID if it exists. Pre 1.4.0 clients will have this
// persisted to the app side container URL.
if let id = FirezoneId.load(.pre140) {
options.merge(["id": id as NSObject]) { _, new in new }
}
try session().startTunnel(options: options)
}
func signOut() throws {
try session([.connected, .connecting, .reasserting]).stopTunnel()
try session().sendProviderMessage(encoder.encode(ProviderMessage.signOut))
func signOut() async throws {
try await sendMessageWithoutResponse(ProviderMessage.signOut)
}
func stop() throws {
try session([.connected, .connecting, .reasserting]).stopTunnel()
}
func toggleInternetResource(enabled: Bool) throws {
try session([.connected]).sendProviderMessage(
encoder.encode(ProviderMessage.internetResourceEnabled(enabled)))
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)
}
}
}
func setAuthURL(_ authURL: URL) async throws {
try await sendMessageWithoutResponse(ProviderMessage.setAuthURL(authURL))
}
func setApiURL(_ apiURL: URL) 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 fetchResources() async throws -> ResourceList {
@@ -102,11 +147,11 @@ class IPCClient {
// Save hash to compare against
self.resourceListHash = Data(SHA256.hash(data: data))
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let decoded = try decoder.decode([Resource].self, from: data)
let decoded = try jsonDecoder.decode([Resource].self, from: data)
self.resourcesListCache = ResourceList.loaded(decoded)
continuation.resume(returning: self.resourcesListCache)
@@ -121,15 +166,7 @@ class IPCClient {
}
func clearLogs() async throws {
return try await withCheckedThrowingContinuation { continuation in
do {
try session().sendProviderMessage(encoder.encode(ProviderMessage.clearLogs)) { _ in
continuation.resume()
}
} catch {
continuation.resume(throwing: error)
}
}
try await sendMessageWithoutResponse(ProviderMessage.clearLogs)
}
func getLogFolderSize() async throws -> Int64 {
@@ -164,8 +201,6 @@ class IPCClient {
appender: @escaping (LogChunk) -> Void,
errorHandler: @escaping (Error) -> Void
) {
let decoder = PropertyListDecoder()
func loop() {
do {
try session().sendProviderMessage(
@@ -178,7 +213,7 @@ class IPCClient {
return
}
guard let chunk = try? decoder.decode(
guard let chunk = try? self.decoder.decode(
LogChunk.self, from: data
)
else {
@@ -260,4 +295,17 @@ class IPCClient {
throw Error.invalidStatus(session.status)
}
private func sendMessageWithoutResponse(_ message: ProviderMessage) async throws {
try await withCheckedThrowingContinuation { continuation in
do {
try session().sendProviderMessage(encoder.encode(message)) { _ in
continuation.resume()
}
} catch {
Log.error(error)
continuation.resume(throwing: error)
}
}
}
}

View File

@@ -41,12 +41,13 @@ public enum Telemetry {
}
}
public static func setEnvironmentOrClose(_ apiURLString: String) {
public static func setEnvironmentOrClose(_ apiURL: URL) {
var environment: String?
let str = apiURL.absoluteString
if apiURLString.starts(with: "wss://api.firezone.dev") {
if str.starts(with: "wss://api.firezone.dev") {
environment = "production"
} else if apiURLString.starts(with: "wss://api.firez.one") {
} else if str.starts(with: "wss://api.firez.one") {
environment = "staging"
}

View File

@@ -12,28 +12,16 @@ import NetworkExtension
enum VPNConfigurationManagerError: Error {
case managerNotInitialized
case savedProtocolConfigurationIsInvalid
var localizedDescription: String {
switch self {
case .managerNotInitialized:
return "NETunnelProviderManager is not yet initialized. Race condition?"
case .savedProtocolConfigurationIsInvalid:
return "Saved protocol configuration is invalid. Check types?"
}
}
}
public class VPNConfigurationManager {
public enum Keys {
static let actorName = "actorName"
static let authBaseURL = "authBaseURL"
static let apiURL = "apiURL"
public static let accountSlug = "accountSlug"
public static let logFilter = "logFilter"
public static let internetResourceEnabled = "internetResourceEnabled"
}
// Persists our tunnel settings
let manager: NETunnelProviderManager
@@ -44,11 +32,10 @@ public class VPNConfigurationManager {
init() async throws {
let protocolConfiguration = NETunnelProviderProtocol()
let manager = NETunnelProviderManager()
let settings = Settings.defaultValue
protocolConfiguration.providerConfiguration = settings.toProviderConfiguration()
protocolConfiguration.providerConfiguration = nil
protocolConfiguration.providerBundleIdentifier = VPNConfigurationManager.bundleIdentifier
protocolConfiguration.serverAddress = settings.apiURL
protocolConfiguration.serverAddress = "Firezone" // can be any non-empty string
manager.localizedDescription = VPNConfigurationManager.bundleDescription
manager.protocolConfiguration = protocolConfiguration
@@ -74,76 +61,6 @@ public class VPNConfigurationManager {
return nil
}
func actorName() throws -> String? {
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
}
return providerConfiguration[Keys.actorName]
}
func internetResourceEnabled() throws -> Bool? {
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
}
// TODO: Store Bool directly in VPN Configuration
if providerConfiguration[Keys.internetResourceEnabled] == "true" {
return true
}
if providerConfiguration[Keys.internetResourceEnabled] == "false" {
return false
}
return nil
}
func save(authResponse: AuthResponse) async throws {
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
var providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
}
providerConfiguration[Keys.actorName] = authResponse.actorName
providerConfiguration[Keys.accountSlug] = authResponse.accountSlug
// Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure.
Telemetry.accountSlug = providerConfiguration[Keys.accountSlug]
protocolConfiguration.providerConfiguration = providerConfiguration
manager.protocolConfiguration = protocolConfiguration
try await enableConfiguration()
}
func save(settings: Settings) async throws {
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
}
var newProviderConfiguration = settings.toProviderConfiguration()
// Don't clobber existing actorName
newProviderConfiguration[Keys.actorName] = providerConfiguration[Keys.actorName]
protocolConfiguration.providerConfiguration = newProviderConfiguration
protocolConfiguration.serverAddress = settings.apiURL
manager.protocolConfiguration = protocolConfiguration
try await enableConfiguration()
// Reconfigure our Telemetry environment in case it changed
Telemetry.setEnvironmentOrClose(settings.apiURL)
}
// 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 {
@@ -152,17 +69,67 @@ public class VPNConfigurationManager {
try await manager.loadFromPreferences()
}
func settings() throws -> Settings {
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String]
else {
throw VPNConfigurationManagerError.savedProtocolConfigurationIsInvalid
}
return Settings.fromProviderConfiguration(providerConfiguration)
}
func session() -> NETunnelProviderSession? {
return manager.connection as? NETunnelProviderSession
}
// 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.
func maybeMigrateConfiguration() async throws {
guard let protocolConfiguration = manager.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration as? [String: String],
let session = session()
else { return }
let ipcClient = IPCClient(session: session)
var migrated = false
if let apiURLString = providerConfiguration["apiURL"],
let apiURL = URL(string: apiURLString),
apiURL.host != nil,
["wss", "ws"].contains(apiURL.scheme) {
try await ipcClient.setApiURL(apiURL)
migrated = true
}
if let authURLString = providerConfiguration["authBaseURL"],
let authURL = URL(string: authURLString),
authURL.host != nil,
["https", "http"].contains(authURL.scheme) {
try await ipcClient.setAuthURL(authURL)
migrated = true
}
if let actorName = providerConfiguration["actorName"] {
try await ipcClient.setActorName(actorName)
migrated = true
}
if let accountSlug = providerConfiguration["accountSlug"] {
try await ipcClient.setAccountSlug(accountSlug)
migrated = true
}
if let logFilter = providerConfiguration["logFilter"],
!logFilter.isEmpty {
try await ipcClient.setLogFilter(logFilter)
migrated = true
}
if let internetResourceEnabled = providerConfiguration["internetResourceEnabled"],
["false", "true"].contains(internetResourceEnabled) {
try await ipcClient.setInternetResourceEnabled(internetResourceEnabled == "true")
migrated = true
}
if !migrated { return }
// Remove fields to prevent confusion if the user sees these in System Settings and wonders why they're stale.
protocolConfiguration.providerConfiguration = nil
protocolConfiguration.serverAddress = "Firezone"
manager.protocolConfiguration = protocolConfiguration
try await manager.saveToPreferences()
try await manager.loadFromPreferences()
}
}

View File

@@ -0,0 +1,47 @@
//
// Configuration.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
import Foundation
public struct Configuration: Codable {
public enum Keys {
public static let authURL = "dev.firezone.configuration.authURL"
public static let apiURL = "dev.firezone.configuration.apiURL"
public static let logFilter = "dev.firezone.configuration.logFilter"
public static let actorName = "dev.firezone.configuration.actorName"
public static let accountSlug = "dev.firezone.configuration.accountSlug"
public static let internetResourceEnabled = "dev.firezone.configuration.internetResourceEnabled"
public static let firezoneId = "dev.firezone.configuration.firezoneId"
}
var actorName: String?
var authURL: URL?
var apiURL: URL?
var logFilter: String?
var accountSlug: String?
var firezoneId: String?
var internetResourceEnabled: Bool?
public init(from dict: [String: Any?]) {
self.actorName = dict[Keys.actorName] as? String
self.authURL = dict[Keys.authURL] as? URL
self.apiURL = dict[Keys.apiURL] as? URL
self.logFilter = dict[Keys.logFilter] as? String
self.accountSlug = dict[Keys.accountSlug] as? String
self.firezoneId = dict[Keys.firezoneId] as? String
self.internetResourceEnabled = dict[Keys.internetResourceEnabled] as? Bool
}
#if DEBUG
public static let defaultAuthURL = URL(string: "https://app.firez.one")!
public static let defaultApiURL = URL(string: "wss://api.firez.one")!
public static let defaultLogFilter = "debug"
#else
public static let defaultAuthURL = URL(string: "https://app.firezone.dev")!
public static let defaultApiURL = URL(string: "wss://api.firezone.dev")!
public static let defaultLogFilter = "info"
#endif
}

View File

@@ -1,64 +0,0 @@
//
// FirezoneId.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
// Convenience wrapper for working with our firezone-id file stored by the
// tunnel process.
import Foundation
/// Prior to 1.4.0, our firezone-id was saved in a file accessible to both the
/// app and tunnel process. Starting with 1.4.0,
/// the macOS client uses a system extension, which makes sharing folders with
/// the app cumbersome, so we move to persisting the firezone-id only from the
/// tunnel process since that is the only place it's used.
///
/// Can be refactored to remove the Version enum all clients >= 1.4.0
public struct FirezoneId {
public enum Version {
case pre140
case post140
}
public static func save(_ id: String) {
guard let fileURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId)?
.appendingPathComponent("firezone-id")
else {
// Nothing we can do about disk errors
return
}
try? id.write(
to: fileURL,
atomically: true,
encoding: .utf8
)
}
public static func load(_ version: Version) -> String? {
let appGroupId = switch version {
case .post140:
BundleHelper.appGroupId
case .pre140:
#if os(macOS)
"47R2M6779T.group.dev.firezone.firezone"
#elseif os(iOS)
"group.dev.firezone.firezone"
#endif
}
guard let containerURL =
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupId),
let id =
try? String(
contentsOf: containerURL.appendingPathComponent("firezone-id"))
else {
return nil
}
return id
}
}

View File

@@ -7,10 +7,19 @@
import Foundation
// TODO: Can we simplify this / abstract it?
// swiftlint:disable cyclomatic_complexity
public enum ProviderMessage: Codable {
case getResourceList(Data)
case getConfiguration(Data)
case signOut
case internetResourceEnabled(Bool)
case setAuthURL(URL)
case setApiURL(URL)
case setLogFilter(String)
case setActorName(String)
case setAccountSlug(String)
case setInternetResourceEnabled(Bool)
case clearLogs
case getLogFolderSize
case exportLogs
@@ -23,8 +32,14 @@ public enum ProviderMessage: Codable {
enum MessageType: String, Codable {
case getResourceList
case getConfiguration
case signOut
case internetResourceEnabled
case setAuthURL
case setApiURL
case setLogFilter
case setActorName
case setAccountSlug
case setInternetResourceEnabled
case clearLogs
case getLogFolderSize
case exportLogs
@@ -35,12 +50,30 @@ public enum ProviderMessage: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(MessageType.self, forKey: .type)
switch type {
case .internetResourceEnabled:
case .setAuthURL:
let value = try container.decode(URL.self, forKey: .value)
self = .setAuthURL(value)
case .setApiURL:
let value = try container.decode(URL.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 = .internetResourceEnabled(value)
self = .setInternetResourceEnabled(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 .signOut:
self = .signOut
case .clearLogs:
@@ -53,15 +86,34 @@ public enum ProviderMessage: Codable {
self = .consumeStopReason
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .internetResourceEnabled(let value):
try container.encode(MessageType.internetResourceEnabled, forKey: .type)
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 .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 .signOut:
try container.encode(MessageType.signOut, forKey: .type)
case .clearLogs:
@@ -75,3 +127,5 @@ public enum ProviderMessage: Codable {
}
}
}
// swiftlint:enable cyclomatic_complexity

View File

@@ -1,97 +0,0 @@
//
// Settings.swift
// (c) 2024 Firezone, Inc.
// LICENSE: Apache-2.0
//
import Foundation
struct Settings: Equatable {
var authBaseURL: String
var apiURL: String
var logFilter: String
var internetResourceEnabled: Bool?
var isValid: Bool {
let authBaseURL = URL(string: authBaseURL)
let apiURL = URL(string: apiURL)
// Technically strings like "foo" are valid URLs, but their host component
// would be nil which crashes the ASWebAuthenticationSession view when
// signing in. We should also validate the scheme, otherwise ftp://
// could be used for example which tries to open the Finder when signing
// in. 🙃
return authBaseURL?.host != nil
&& apiURL?.host != nil
&& ["http", "https"].contains(authBaseURL?.scheme)
&& ["ws", "wss"].contains(apiURL?.scheme)
&& !logFilter.isEmpty
}
// Convert provider configuration (which may have empty fields if it was tampered with) to Settings
static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings {
if let providerConfiguration = providerConfiguration as? [String: String] {
return Settings(
authBaseURL: providerConfiguration[VPNConfigurationManager.Keys.authBaseURL]
?? Settings.defaultValue.authBaseURL,
apiURL: providerConfiguration[VPNConfigurationManager.Keys.apiURL]
?? Settings.defaultValue.apiURL,
logFilter: providerConfiguration[VPNConfigurationManager.Keys.logFilter]
?? Settings.defaultValue.logFilter,
internetResourceEnabled: getInternetResourceEnabled(
internetResourceEnabled: providerConfiguration[VPNConfigurationManager.Keys.internetResourceEnabled])
)
} else {
return Settings.defaultValue
}
}
static private func getInternetResourceEnabled(internetResourceEnabled: String?) -> Bool? {
guard let internetResourceEnabled = internetResourceEnabled,
let jsonData = internetResourceEnabled.data(using: .utf8)
else { return nil }
return try? JSONDecoder().decode(Bool?.self, from: jsonData)
}
// Used for initializing a new providerConfiguration from Settings
func toProviderConfiguration() -> [String: String] {
guard let data = try? JSONEncoder().encode(internetResourceEnabled),
let string = String(data: data, encoding: .utf8)
else {
fatalError("internetResourceEnabled should be encodable")
}
return [
VPNConfigurationManager.Keys.authBaseURL: authBaseURL,
VPNConfigurationManager.Keys.apiURL: apiURL,
VPNConfigurationManager.Keys.logFilter: logFilter,
VPNConfigurationManager.Keys.internetResourceEnabled: string
]
}
static let defaultValue: Settings = {
// Note: To see what the connlibLogFilterString values mean, see:
// https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html
#if DEBUG
Settings(
authBaseURL: "https://app.firez.one",
apiURL: "wss://api.firez.one",
logFilter: "debug",
internetResourceEnabled: nil
)
#else
Settings(
authBaseURL: "https://app.firezone.dev",
apiURL: "wss://api.firezone.dev",
logFilter: "info",
internetResourceEnabled: nil
)
#endif
}()
}
extension Settings: CustomStringConvertible {
var description: String {
"(\(authBaseURL), \(apiURL), \(logFilter)"
}
}

View File

@@ -16,8 +16,9 @@ struct WebAuthSession {
static let anchor = PresentationAnchor()
static func signIn(store: Store) async throws {
guard let authURL = store.authURL(),
let authClient = try? AuthClient(authURL: authURL),
let authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL
guard let authClient = try? AuthClient(authURL: authURL),
let url = try? authClient.build()
else {
// Should never get here because we perform URL validation on input, but handle this just in case

View File

@@ -17,24 +17,23 @@ import AppKit
public final class Store: ObservableObject {
@Published private(set) var favorites = Favorites()
@Published private(set) var resourceList: ResourceList = .loading
@Published private(set) var actorName: String?
// Make our tunnel configuration convenient for SettingsView to consume
@Published private(set) var settings = Settings.defaultValue
// UserDefaults-backed app configuration that will publish updates to SwiftUI components
@Published private(set) var configuration: Configuration?
// Enacapsulate Tunnel status here to make it easier for other components
// to observe
// Enacapsulate Tunnel status here to make it easier for other components to observe
@Published private(set) var status: NEVPNStatus?
// User notifications
@Published private(set) var decision: UNAuthorizationStatus?
@Published private(set) var internetResourceEnabled: Bool?
#if os(macOS)
// Track whether our system extension has been installed (macOS)
@Published private(set) var systemExtensionStatus: SystemExtensionStatus?
#endif
var firezoneId: String?
let sessionNotification = SessionNotification()
private var resourcesTimer: Timer?
@@ -78,11 +77,13 @@ public final class Store: ObservableObject {
do {
// Try to load existing configuration
if let manager = try await VPNConfigurationManager.load() {
try await manager.maybeMigrateConfiguration()
self.vpnConfigurationManager = manager
self.settings = try manager.settings()
try await setupTunnelObservers()
try await manager.enableConfiguration()
try ipcClient().start()
self.configuration = try await ipcClient().getConfiguration()
Telemetry.firezoneId = configuration?.firezoneId
} else {
status = .invalid
}
@@ -105,13 +106,6 @@ public final class Store: ObservableObject {
status = newStatus
if status == .connected {
// Load saved actorName
actorName = try? manager().actorName()
// Load saved internet resource status
internetResourceEnabled = try? manager().internetResourceEnabled()
// Load Resources
beginUpdatingResources()
} else {
endUpdatingResources()
@@ -179,6 +173,9 @@ public final class Store: ObservableObject {
// Create a new VPN configuration in system settings.
self.vpnConfigurationManager = try await VPNConfigurationManager()
self.configuration = try await ipcClient().getConfiguration()
Telemetry.firezoneId = configuration?.firezoneId
try await setupTunnelObservers()
}
@@ -204,45 +201,74 @@ public final class Store: ObservableObject {
self.decision = try await sessionNotification.askUserForNotificationPermissions()
}
func authURL() -> URL? {
return URL(string: settings.authBaseURL)
}
func stop() throws {
try ipcClient().stop()
}
func signIn(authResponse: AuthResponse) async throws {
// Save actorName
self.actorName = authResponse.actorName
try await setActorName(authResponse.actorName)
try await setAccountSlug(authResponse.accountSlug)
try await manager().save(authResponse: authResponse)
try await manager().enableConfiguration()
// Bring the tunnel up and send it a token to start
try ipcClient().start(token: authResponse.token)
}
func signOut() throws {
try ipcClient().signOut()
func signOut() async throws {
try await ipcClient().signOut()
}
func clearLogs() async throws {
try await ipcClient().clearLogs()
}
func saveSettings(_ newSettings: Settings) async throws {
try await manager().save(settings: newSettings)
self.settings = newSettings
}
func toggleInternetResource() async throws {
internetResourceEnabled = !(internetResourceEnabled ?? false)
settings.internetResourceEnabled = internetResourceEnabled
try ipcClient().toggleInternetResource(enabled: internetResourceEnabled == true)
try await manager().save(settings: settings)
let enabled = configuration?.internetResourceEnabled == true
try await setInternetResourceEnabled(!enabled)
}
// MARK: App configuration setters
func setActorName(_ actorName: String) async throws {
try await ipcClient().setActorName(actorName)
self.configuration?.actorName = actorName
}
func setAccountSlug(_ accountSlug: String) async throws {
try await ipcClient().setAccountSlug(accountSlug)
// Configure our Telemetry environment, closing if we're definitely not running against Firezone infrastructure.
Telemetry.accountSlug = accountSlug
}
func setAuthURL(_ authURL: URL) async throws {
try await ipcClient().setAuthURL(authURL)
self.configuration?.authURL = authURL
}
func setApiURL(_ apiURL: URL) async throws {
try await ipcClient().setApiURL(apiURL)
// Reconfigure our Telemetry environment in case it changed
Telemetry.setEnvironmentOrClose(apiURL)
self.configuration?.apiURL = apiURL
}
func setLogFilter(_ logFilter: String) async throws {
try await ipcClient().setLogFilter(logFilter)
self.configuration?.logFilter = logFilter
}
func setInternetResourceEnabled(_ enabled: Bool) async throws {
try await ipcClient().setInternetResourceEnabled(enabled)
self.configuration?.internetResourceEnabled = enabled
}
// MARK: Private functions
private func start(token: String? = nil) throws {
try ipcClient().start(token: token)
}

View File

@@ -37,8 +37,8 @@ public struct AppView: View {
WindowDefinition.main.openWindow()
}
// Close window upon launch for day-to-day use
if status != .invalid && systemExtensionStatus == .installed && FirezoneId.load(.pre140) != nil {
// Close window for day to day use
if status != .invalid && systemExtensionStatus == .installed && launchedBefore() {
WindowDefinition.main.window()?.close()
}
})
@@ -75,6 +75,13 @@ public struct AppView: View {
}
}
}
private static func launchedBefore() -> Bool {
let bool = UserDefaults.standard.bool(forKey: "launchedBefore")
UserDefaults.standard.set(true, forKey: "launchedBefore")
return bool
}
#endif
public init() {}

View File

@@ -285,7 +285,7 @@ public final class MenuBar: NSObject, ObservableObject {
}
}
lastShownFavorites = newFavorites
wasInternetResourceEnabled = store.internetResourceEnabled
wasInternetResourceEnabled = store.configuration?.internetResourceEnabled
}
func populateOtherResourcesMenu(_ newOthers: [Resource]) {
@@ -313,7 +313,7 @@ public final class MenuBar: NSObject, ObservableObject {
}
}
lastShownOthers = newOthers
wasInternetResourceEnabled = store.internetResourceEnabled
wasInternetResourceEnabled = store.configuration?.internetResourceEnabled
}
func updateStatusItemIcon() {
@@ -350,7 +350,7 @@ public final class MenuBar: NSObject, ObservableObject {
signOutMenuItem.isHidden = true
settingsMenuItem.target = self
case .connected, .reasserting, .connecting:
let title = "Signed in as \(store.actorName ?? "Unknown User")"
let title = "Signed in as \(store.configuration?.actorName ?? "Unknown User")"
signInMenuItem.title = title
signInMenuItem.target = nil
signOutMenuItem.isHidden = false
@@ -467,7 +467,7 @@ public final class MenuBar: NSObject, ObservableObject {
return false
}
return wasInternetResourceEnabled != store.internetResourceEnabled
return wasInternetResourceEnabled != store.configuration?.internetResourceEnabled
}
func refreshUpdateItem() {
@@ -503,7 +503,7 @@ public final class MenuBar: NSObject, ObservableObject {
}
func internetResourceTitle(resource: Resource) -> String {
let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled
let status = store.configuration?.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled
return status + " " + resource.name
}
@@ -526,7 +526,7 @@ public final class MenuBar: NSObject, ObservableObject {
}
func internetResourceToggleTitle() -> String {
store.internetResourceEnabled == true ? "Disable this resource" : "Enable this resource"
store.configuration?.internetResourceEnabled == true ? "Disable this resource" : "Enable this resource"
}
// TODO: Refactor this when refactoring for macOS 13
@@ -689,7 +689,7 @@ public final class MenuBar: NSObject, ObservableObject {
}
@objc func signOutButtonTapped() {
do { try store.signOut() } catch { Log.error(error) }
Task { do { try await store.signOut() } catch { Log.error(error) } }
}
@objc func grantPermissionMenuItemTapped() {
@@ -723,10 +723,9 @@ public final class MenuBar: NSObject, ObservableObject {
}
@objc func adminPortalButtonTapped() {
guard let url = URL(string: store.settings.authBaseURL)
else { return }
let authURL = store.configuration?.authURL ?? Configuration.defaultAuthURL
Task { await NSWorkspace.shared.openAsync(url) }
Task { await NSWorkspace.shared.openAsync(authURL) }
}
@objc func updateAvailableButtonTapped() {

View File

@@ -235,7 +235,7 @@ struct ToggleInternetResourceButton: View {
@EnvironmentObject var store: Store
private func toggleResourceEnabledText() -> String {
if store.internetResourceEnabled == true {
if store.configuration?.internetResourceEnabled == true {
"Disable this resource"
} else {
"Enable this resource"

View File

@@ -81,7 +81,7 @@ struct ResourceSection: View {
@EnvironmentObject var store: Store
private func internetResourceTitle(resource: Resource) -> String {
let status = store.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled
let status = store.configuration?.internetResourceEnabled == true ? StatusSymbol.enabled : StatusSymbol.disabled
return status + " " + resource.name
}

View File

@@ -64,12 +64,101 @@ extension FileManager {
}
}
// TODO: Refactor body length
@MainActor
class SettingsViewModel: ObservableObject {
private let store: Store
private var cancellables = Set<AnyCancellable>()
@Published var authURLString: String
@Published var apiURLString: String
@Published var logFilterString: String
@Published var areSettingsDefault = true
@Published var areSettingsValid = true
@Published var areSettingsSaved = true
init(store: Store) {
self.store = store
self.authURLString = store.configuration?.authURL?.absoluteString ?? Configuration.defaultAuthURL.absoluteString
self.apiURLString = store.configuration?.apiURL?.absoluteString ?? Configuration.defaultApiURL.absoluteString
self.logFilterString = store.configuration?.logFilter ?? Configuration.defaultLogFilter
updateDerivedState()
Publishers.CombineLatest3($authURLString, $apiURLString, $logFilterString)
.receive(on: RunLoop.main)
.sink { [weak self] (_, _, _) in
guard let self = self else { return }
self.updateDerivedState()
}
.store(in: &cancellables)
}
private func updateDerivedState() {
self.areSettingsSaved = (self.authURLString == store.configuration?.authURL?.absoluteString &&
self.apiURLString == store.configuration?.apiURL?.absoluteString &&
self.logFilterString == store.configuration?.logFilter)
if let apiURL = URL(string: apiURLString),
let authURL = URL(string: authURLString),
authURL.host != nil,
apiURL.host != nil,
["https", "http"].contains(authURL.scheme),
["wss", "ws"].contains(apiURL.scheme),
!logFilterString.isEmpty {
self.areSettingsValid = true
} else {
self.areSettingsValid = false
}
self.areSettingsDefault = (self.authURLString == Configuration.defaultAuthURL.absoluteString &&
self.apiURLString == Configuration.defaultApiURL.absoluteString &&
self.logFilterString == Configuration.defaultLogFilter)
}
func applySettingsToStore() throws {
guard let authURL = URL(string: authURLString),
let apiURL = URL(string: apiURLString)
else {
Log.warning("Unexpectedly invalid settings")
return
}
Task {
try await store.setApiURL(apiURL)
try await store.setLogFilter(logFilterString)
try await store.setAuthURL(authURL)
updateDerivedState()
}
}
func revertToDefaultSettings() {
self.authURLString = Configuration.defaultAuthURL.absoluteString
self.apiURLString = Configuration.defaultApiURL.absoluteString
self.logFilterString = Configuration.defaultLogFilter
}
func reloadSettingsFromStore() {
self.authURLString = store.configuration?.authURL?.absoluteString ?? Configuration.defaultAuthURL.absoluteString
self.apiURLString = store.configuration?.apiURL?.absoluteString ?? Configuration.defaultApiURL.absoluteString
self.logFilterString = store.configuration?.logFilter ?? Configuration.defaultLogFilter
}
}
// 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 {
@EnvironmentObject var store: Store
@StateObject private var viewModel: SettingsViewModel
@Environment(\.dismiss) var dismiss
@State var settings = Settings.defaultValue
private let store: Store
enum ConfirmationAlertContinueAction: Int {
case none
@@ -81,9 +170,9 @@ public struct SettingsView: View {
case .none:
break
case .saveSettings:
view.saveSettings()
Task { do { try await view.saveSettings() } catch { Log.error(error) } }
case .saveAllSettingsAndDismiss:
view.saveAllSettingsAndDismiss()
Task { do { try await view.saveAllSettingsAndDismiss() } catch { Log.error(error) } }
}
}
}
@@ -117,7 +206,10 @@ public struct SettingsView: View {
)
}
public init() {}
public init(store: Store) {
self.store = store
_viewModel = StateObject(wrappedValue: SettingsViewModel(store: store))
}
public var body: some View {
#if os(iOS)
@@ -128,7 +220,7 @@ public struct SettingsView: View {
Image(systemName: "slider.horizontal.3")
Text("Advanced")
}
.badge(settings.isValid ? nil : "!")
.badge(viewModel.areSettingsValid ? nil : "!")
logsTab
.tabItem {
Image(systemName: "doc.text")
@@ -147,7 +239,7 @@ public struct SettingsView: View {
}
}
.disabled(
(settings == store.settings || !settings.isValid)
(viewModel.areSettingsSaved || !viewModel.areSettingsValid)
)
}
ToolbarItem(placement: .navigationBarLeading) {
@@ -175,9 +267,6 @@ public struct SettingsView: View {
Text("Changing settings will sign you out and disconnect you from resources")
}
)
.onAppear {
settings = store.settings
}
#elseif os(macOS)
VStack {
@@ -209,9 +298,6 @@ public struct SettingsView: View {
Text("Changing settings will sign you out and disconnect you from resources")
}
)
.onAppear {
settings = store.settings
}
.onDisappear(perform: { self.reloadSettings() })
#else
#error("Unsupported platform")
@@ -227,28 +313,19 @@ public struct SettingsView: View {
Form {
TextField(
"Auth Base URL:",
text: Binding(
get: { settings.authBaseURL },
set: { settings.authBaseURL = $0 }
),
text: $viewModel.authURLString,
prompt: Text(PlaceholderText.authBaseURL)
)
TextField(
"API URL:",
text: Binding(
get: { settings.apiURL },
set: { settings.apiURL = $0 }
),
text: $viewModel.apiURLString,
prompt: Text(PlaceholderText.apiURL)
)
TextField(
"Log Filter:",
text: Binding(
get: { settings.logFilter },
set: { settings.logFilter = $0 }
),
text: $viewModel.logFilterString,
prompt: Text(PlaceholderText.logFilter)
)
@@ -268,16 +345,15 @@ public struct SettingsView: View {
}
}
)
.disabled(settings == store.settings || !store.settings.isValid)
.disabled(viewModel.areSettingsSaved || !viewModel.areSettingsValid)
Button(
"Reset to Defaults",
action: {
settings = Settings.defaultValue
store.favorites.reset()
viewModel.revertToDefaultSettings()
}
)
.disabled(store.favorites.isEmpty() && settings == Settings.defaultValue)
.disabled(viewModel.areSettingsDefault)
}
.padding(.top, 5)
}
@@ -303,10 +379,7 @@ public struct SettingsView: View {
.font(.caption)
TextField(
PlaceholderText.authBaseURL,
text: Binding(
get: { settings.authBaseURL },
set: { settings.authBaseURL = $0 }
)
text: $viewModel.authURLString
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
@@ -318,10 +391,7 @@ public struct SettingsView: View {
.font(.caption)
TextField(
PlaceholderText.apiURL,
text: Binding(
get: { settings.apiURL },
set: { settings.apiURL = $0 }
)
text: $viewModel.apiURLString
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
@@ -333,10 +403,7 @@ public struct SettingsView: View {
.font(.caption)
TextField(
PlaceholderText.logFilter,
text: Binding(
get: { settings.logFilter },
set: { settings.logFilter = $0 }
)
text: $viewModel.logFilterString
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
@@ -347,11 +414,10 @@ public struct SettingsView: View {
Button(
"Reset to Defaults",
action: {
settings = Settings.defaultValue
store.favorites.reset()
viewModel.revertToDefaultSettings()
}
)
.disabled(store.favorites.isEmpty() && settings == Settings.defaultValue)
.disabled(viewModel.areSettingsDefault)
Spacer()
}
},
@@ -477,13 +543,13 @@ public struct SettingsView: View {
#endif
}
func saveAllSettingsAndDismiss() {
saveSettings()
func saveAllSettingsAndDismiss() async throws {
try await saveSettings()
dismiss()
}
func reloadSettings() {
settings = store.settings
viewModel.reloadSettingsFromStore()
dismiss()
}
@@ -577,18 +643,16 @@ public struct SettingsView: View {
}
}
func saveSettings() {
Task {
do {
if [.connected, .connecting, .reasserting].contains(store.status) {
try self.store.signOut()
}
try await store.saveSettings(settings)
} catch {
Log.error(error)
func saveSettings() async throws {
do {
if [.connected, .connecting, .reasserting].contains(store.status) {
try await self.store.signOut()
}
} catch {
Log.error(error)
}
try viewModel.applySettingsToStore()
}
// Calculates the total size of our logs by summing the size of the

View File

@@ -40,7 +40,7 @@ struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_n
)
}
.sheet(isPresented: $isSettingsPresented) {
SettingsView()
SettingsView(store: store)
}
.navigationViewStyle(StackNavigationViewStyle())
}
@@ -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.actorName ?? "Unknown user")")
Text("Signed in as \(store.configuration?.actorName ?? "Unknown user")")
Button(
action: {
signOutButtonTapped()
@@ -120,17 +120,19 @@ struct iOSNavigationView<Content: View>: View { // swiftlint:disable:this type_n
}
func signOutButtonTapped() {
do {
try store.signOut()
} catch {
Log.error(error)
Task {
do {
try await store.signOut()
} catch {
Log.error(error)
self.errorHandler.handle(
ErrorAlert(
title: "Error signing out",
error: error
self.errorHandler.handle(
ErrorAlert(
title: "Error signing out",
error: error
)
)
)
}
}
}
}

View File

@@ -164,7 +164,7 @@ class Adapter {
}
/// Currently disabled resources
private var internetResourceEnabled: Bool = false
private var internetResourceEnabled: Bool
/// Cache of internet resource
private var internetResource: Resource?
@@ -173,14 +173,14 @@ class Adapter {
private var resourceListJSON: String?
/// Starting parameters
private let apiURL: String
private let apiURL: URL
private let token: Token
private let id: String
private let logFilter: String
private let connlibLogFolderPath: String
init(
apiURL: String,
apiURL: URL,
token: Token,
id: String,
logFilter: String,
@@ -207,7 +207,7 @@ class Adapter {
}
/// Start the tunnel.
public func start() throws {
func start() throws {
Log.log("Adapter.start")
guard session == nil else {
@@ -223,7 +223,7 @@ class Adapter {
// Grab a session pointer
session = try WrappedSession.connect(
apiURL,
"\(apiURL)",
"\(token)",
"\(id)",
"\(Telemetry.accountSlug!)",
@@ -255,7 +255,7 @@ class Adapter {
///
/// This can happen before the tunnel is in the tunnelReady state, such as if the portal
/// is slow to send the init.
public func stop() {
func stop() {
Log.log("Adapter.stop")
// Assigning `nil` will invoke `Drop` on the Rust side
@@ -267,7 +267,7 @@ class Adapter {
/// Get the current set of resources in the completionHandler, only returning
/// them if the resource list has changed.
public func getResourcesIfVersionDifferentFrom(
func getResourcesIfVersionDifferentFrom(
hash: Data, completionHandler: @escaping (String?) -> Void
) {
// This is async to avoid blocking the main UI thread
@@ -290,7 +290,7 @@ class Adapter {
return (try? decoder.decode([Resource].self, from: resourceList.data(using: .utf8)!)) ?? []
}
public func setInternetResourceEnabled(_ enabled: Bool) {
func setInternetResourceEnabled(_ enabled: Bool) {
workQueue.async { [weak self] in
guard let self = self else { return }
@@ -299,7 +299,7 @@ class Adapter {
}
}
public func resourcesUpdated() {
func resourcesUpdated() {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
@@ -356,7 +356,7 @@ extension Adapter {
extension Adapter: CallbackHandlerDelegate {
// swiftlint:disable:next function_parameter_count
public func onSetInterfaceConfig(
func onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
searchDomain: String?,
@@ -396,7 +396,7 @@ extension Adapter: CallbackHandlerDelegate {
}
}
public func onUpdateResources(resourceList: String) {
func onUpdateResources(resourceList: String) {
// This is a queued callback to ensure ordering
workQueue.async { [weak self] in
guard let self = self else { return }
@@ -410,7 +410,7 @@ extension Adapter: CallbackHandlerDelegate {
}
}
public func onDisconnect(error: String) {
func onDisconnect(error: String) {
// Immediately invalidate our session pointer to prevent workQueue items from trying to use it.
// Assigning to `nil` will invoke `Drop` on the Rust side.
session = nil

View File

@@ -0,0 +1,107 @@
//
// 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 encoder = PropertyListEncoder()
private var userDefaults: UserDefaults
var authURL: URL? {
get { userDefaults.url(forKey: Configuration.Keys.authURL) }
set { userDefaults.set(newValue, forKey: Configuration.Keys.authURL) }
}
var apiURL: URL? {
get { userDefaults.url(forKey: Configuration.Keys.apiURL) }
set { userDefaults.set(newValue, forKey: Configuration.Keys.apiURL) }
}
var logFilter: String? {
get { userDefaults.string(forKey: Configuration.Keys.logFilter) }
set { userDefaults.set(newValue, forKey: Configuration.Keys.logFilter) }
}
var actorName: String? {
get { userDefaults.string(forKey: Configuration.Keys.actorName) }
set { userDefaults.set(newValue, forKey: Configuration.Keys.actorName) }
}
var accountSlug: String? {
get { userDefaults.string(forKey: Configuration.Keys.accountSlug) }
set { userDefaults.set(newValue, forKey: Configuration.Keys.accountSlug) }
}
var internetResourceEnabled: Bool? {
get { userDefaults.bool(forKey: Configuration.Keys.internetResourceEnabled) }
set { userDefaults.set(newValue, forKey: Configuration.Keys.internetResourceEnabled) }
}
var firezoneId: String? {
get { userDefaults.string(forKey: Configuration.Keys.firezoneId) }
set { userDefaults.set(newValue, forKey: Configuration.Keys.firezoneId) }
}
private init() {
self.userDefaults = UserDefaults.standard
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: BundleHelper.appGroupId),
let idFromFile = try? String(contentsOf: containerURL.appendingPathComponent("firezone-id")) {
self.firezoneId = idFromFile
try? FileManager.default.removeItem(at: containerURL.appendingPathComponent("firezone-id"))
Telemetry.firezoneId = idFromFile
return
}
if let firezoneId {
Telemetry.firezoneId = firezoneId
return
}
self.firezoneId = UUID().uuidString
Telemetry.firezoneId = firezoneId
}
func toDataIfChanged(hash: Data?) -> Data? {
var dict: [String: Any] = [:]
dict[Configuration.Keys.accountSlug] = accountSlug
dict[Configuration.Keys.actorName] = actorName
dict[Configuration.Keys.firezoneId] = firezoneId
dict[Configuration.Keys.internetResourceEnabled] = internetResourceEnabled
dict[Configuration.Keys.authURL] = authURL
dict[Configuration.Keys.apiURL] = apiURL
dict[Configuration.Keys.logFilter] = logFilter
let configuration = Configuration(from: dict)
do {
let encoded = try encoder.encode(configuration)
let hashData = Data(SHA256.hash(data: encoded))
if hash == hashData {
// same
return nil
}
return encoded
} catch {
Log.error(error)
}
return nil
}
}

View File

@@ -10,7 +10,10 @@ import System
import os
enum PacketTunnelProviderError: Error {
case savedProtocolConfigurationIsInvalid(String)
case apiURLIsInvalid
case logFilterIsInvalid
case accountSlugIsInvalid
case firezoneIdIsInvalid
case tokenNotFoundInKeychain
}
@@ -31,8 +34,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
super.init()
}
// TODO: Refactor this to shorten function body
// swiftlint:disable:next function_body_length
override func startTunnel(
options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void
@@ -50,49 +51,25 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
// Try to save the token back to the Keychain but continue if we can't
do { try token.save() } catch { Log.error(error) }
// Use and persist the provided ID or try loading it from disk,
// generating a new one if both of those are nil.
let id = loadAndSaveFirezoneId(from: options)
// The firezone id should be initialized by now
guard let id = ConfigurationManager.shared.firezoneId
else {
throw PacketTunnelProviderError.firezoneIdIsInvalid
}
// Now we should have a token, so continue connecting
guard let apiURL = protocolConfiguration.serverAddress
else {
throw PacketTunnelProviderError
.savedProtocolConfigurationIsInvalid("serverAddress")
}
let apiURL = ConfigurationManager.shared.apiURL ?? Configuration.defaultApiURL
// Reconfigure our Telemetry environment now that we know the API URL
Telemetry.setEnvironmentOrClose(apiURL)
guard
let providerConfiguration = (protocolConfiguration as? NETunnelProviderProtocol)?
.providerConfiguration as? [String: String],
let logFilter = providerConfiguration[VPNConfigurationManager.Keys.logFilter]
else {
throw PacketTunnelProviderError
.savedProtocolConfigurationIsInvalid("providerConfiguration.logFilter")
}
let logFilter = ConfigurationManager.shared.logFilter ?? Configuration.defaultLogFilter
// Hydrate telemetry account slug
guard let accountSlug = providerConfiguration[VPNConfigurationManager.Keys.accountSlug]
else {
// This can happen if the user deletes the VPN configuration while it's
// connected. The system will try to restart us with a fresh config
// once the user fixes the problem, but we'd rather not connect
// without a slug.
throw PacketTunnelProviderError
.savedProtocolConfigurationIsInvalid("providerConfiguration.accountSlug")
}
let accountSlug = ConfigurationManager.shared.accountSlug
Telemetry.accountSlug = accountSlug
let internetResourceEnabled: Bool =
if let internetResourceEnabledJSON = providerConfiguration[
VPNConfigurationManager.Keys.internetResourceEnabled]?.data(using: .utf8) {
(try? JSONDecoder().decode(Bool.self, from: internetResourceEnabledJSON )) ?? false
} else {
false
}
let internetResourceEnabled = ConfigurationManager.shared.internetResourceEnabled ?? false
let adapter = Adapter(
apiURL: apiURL,
@@ -111,11 +88,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
// `connected`.
completionHandler(nil)
} catch let error as PacketTunnelProviderError {
// These are expected, no need to log them
completionHandler(error)
} catch {
Log.error(error)
@@ -166,31 +138,71 @@ 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
override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) {
guard let providerMessage = try? PropertyListDecoder().decode(ProviderMessage.self, from: message) else { return }
do {
let providerMessage = try PropertyListDecoder().decode(ProviderMessage.self, from: message)
switch providerMessage {
case .internetResourceEnabled(let value):
adapter?.setInternetResourceEnabled(value)
case .signOut:
do {
try Token.delete()
} catch {
Log.error(error)
}
case .getResourceList(let value):
adapter?.getResourcesIfVersionDifferentFrom(hash: value) { resourceListJSON in
completionHandler?(resourceListJSON?.data(using: .utf8))
}
case .clearLogs:
clearLogs(completionHandler)
case .getLogFolderSize:
getLogFolderSize(completionHandler)
case .exportLogs:
exportLogs(completionHandler!)
switch providerMessage {
case .getConfiguration(let hash):
let configurationPayload = ConfigurationManager.shared.toDataIfChanged(hash: hash)
completionHandler?(configurationPayload)
case .setAuthURL(let authURL):
ConfigurationManager.shared.authURL = authURL
completionHandler?(nil)
case .setApiURL(let apiURL):
ConfigurationManager.shared.apiURL = apiURL
completionHandler?(nil)
case .setActorName(let actorName):
ConfigurationManager.shared.actorName = actorName
completionHandler?(nil)
case .setAccountSlug(let accountSlug):
ConfigurationManager.shared.accountSlug = accountSlug
completionHandler?(nil)
case .setLogFilter(let logFilter):
ConfigurationManager.shared.logFilter = logFilter
completionHandler?(nil)
case .setInternetResourceEnabled(let enabled):
ConfigurationManager.shared.internetResourceEnabled = enabled
adapter?.setInternetResourceEnabled(enabled)
completionHandler?(nil)
case .signOut:
do {
try Token.delete()
Task {
await stopTunnel(with: .userInitiated)
completionHandler?(nil)
}
} catch {
Log.error(error)
completionHandler?(nil)
}
case .getResourceList(let hash):
guard let adapter = adapter
else {
Log.warning("Adapter is nil")
completionHandler?(nil)
case .consumeStopReason:
consumeStopReason(completionHandler!)
return
}
adapter.getResourcesIfVersionDifferentFrom(hash: hash) { resourceListJSON in
completionHandler?(resourceListJSON?.data(using: .utf8))
}
case .clearLogs:
clearLogs(completionHandler)
case .getLogFolderSize:
getLogFolderSize(completionHandler)
case .exportLogs:
exportLogs(completionHandler!)
case .consumeStopReason:
consumeStopReason(completionHandler!)
}
} catch {
Log.error(error)
completionHandler?(nil)
}
}
@@ -208,20 +220,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
return Token(passedToken) ?? keychainToken
}
func loadAndSaveFirezoneId(from options: [String: NSObject]?) -> String {
let passedId = options?["id"] as? String
let persistedId = FirezoneId.load(.post140)
let id = passedId ?? persistedId ?? UUID().uuidString
FirezoneId.save(id)
// Hydrate the telemetry userId with our firezone id
Telemetry.firezoneId = id
return id
}
func clearLogs(_ completionHandler: ((Data?) -> Void)? = nil) {
do {
try Log.clear(in: SharedAccess.logFolderURL)