From 83b2c7a71a4b09df17741fa80a118663fcfd6b47 Mon Sep 17 00:00:00 2001 From: Jamil Date: Sun, 23 Feb 2025 18:28:29 -0800 Subject: [PATCH] refactor(apple): Collapse ViewModels to app-wide Store (#8218) Our application state is incredibly simple, only consisting of a handful of properties. Throughout our codebase, we use a singular global state store called `Store`. We then inject this as the singular piece of state into each view's model. This is unnecessary boilerplate and leads to lots of duplicated logic. Instead we refactor away (nearly) all of the application's view models and instead use an `@EnvironmentObject` to inject the store into each view. This convention drastically simplifies state tracking logic and boilerplate in the views. --- .../Firezone/Application/FirezoneApp.swift | 38 +- .../FirezoneKit/Models/Favorites.swift | 8 +- .../Sources/FirezoneKit/Stores/Store.swift | 59 +- .../Sources/FirezoneKit/Views/AppView.swift | 151 +-- .../FirezoneKit/Views/FirstTimeView.swift | 4 +- .../Views/GrantNotificationsView.swift | 50 +- .../FirezoneKit/Views/GrantVPNView.swift | 141 +- .../Sources/FirezoneKit/Views/MenuBar.swift | 98 +- .../FirezoneKit/Views/ResourceView.swift | 24 +- .../FirezoneKit/Views/SessionView.swift | 104 +- .../FirezoneKit/Views/SettingsView.swift | 1130 ++++++++--------- .../FirezoneKit/Views/WelcomeView.swift | 13 +- .../FirezoneKit/Views/iOSNavigationView.swift | 59 +- 13 files changed, 870 insertions(+), 1009 deletions(-) diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index 702c83f29..54e083ee4 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -13,8 +13,6 @@ struct FirezoneApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate #endif - @StateObject var favorites: Favorites - @StateObject var appViewModel: AppViewModel @StateObject var store: Store @StateObject private var errorHandler = GlobalErrorHandler() @@ -22,47 +20,47 @@ struct FirezoneApp: App { // Initialize Telemetry as early as possible Telemetry.start() - let favorites = Favorites() let store = Store() - _favorites = StateObject(wrappedValue: favorites) _store = StateObject(wrappedValue: store) - _appViewModel = StateObject(wrappedValue: AppViewModel(favorites: favorites, store: store)) #if os(macOS) appDelegate.store = store - appDelegate.favorites = favorites #endif } var body: some Scene { #if os(iOS) WindowGroup { - AppView(model: appViewModel).environmentObject(errorHandler) + AppView() + .environmentObject(errorHandler) + .environmentObject(store) } #elseif os(macOS) WindowGroup( "Welcome to Firezone", - id: AppViewModel.WindowDefinition.main.identifier + id: AppView.WindowDefinition.main.identifier ) { - if let menuBar = appDelegate.menuBar { - // menuBar will be initialized by this point - AppView(model: appViewModel).environmentObject(menuBar) - } else { + if appDelegate.menuBar == nil { ProgressView("Loading...") + } else { + // menuBar will be initialized by this point + AppView() + .environmentObject(store) } } .handlesExternalEvents( - matching: [AppViewModel.WindowDefinition.main.externalEventMatchString] + matching: [AppView.WindowDefinition.main.externalEventMatchString] ) // macOS doesn't have Sheets, need to use another Window group to show settings WindowGroup( "Settings", - id: AppViewModel.WindowDefinition.settings.identifier + id: AppView.WindowDefinition.settings.identifier ) { - SettingsView(favorites: favorites, model: SettingsViewModel(store: store)) + SettingsView() + .environmentObject(store) } .handlesExternalEvents( - matching: [AppViewModel.WindowDefinition.settings.externalEventMatchString] + matching: [AppView.WindowDefinition.settings.externalEventMatchString] ) #endif } @@ -71,18 +69,16 @@ struct FirezoneApp: App { #if os(macOS) @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - var favorites: Favorites? var menuBar: MenuBar? var store: Store? func applicationDidFinishLaunching(_: Notification) { - if let store, - let favorites { - menuBar = MenuBar(model: SessionViewModel(favorites: favorites, store: store)) + if let store { + menuBar = MenuBar(store: store) } // SwiftUI will show the first window group, so close it on launch - _ = AppViewModel.WindowDefinition.allCases.map { $0.window()?.close() } + _ = AppView.WindowDefinition.allCases.map { $0.window()?.close() } // Show alert for macOS 15.0.x which has issues with Network Extensions. maybeShowOutdatedAlert() diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift index 0f2615a58..5885bda5e 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/Favorites.swift @@ -1,8 +1,8 @@ import Foundation -public class Favorites: ObservableObject { +public final class Favorites: ObservableObject { private static let key = "favoriteResourceIDs" - @Published private(set) var ids: Set + private var ids: Set public init() { ids = Favorites.load() @@ -30,6 +30,10 @@ public class Favorites: ObservableObject { save() } + func isEmpty() -> Bool { + return ids.isEmpty + } + private func save() { // It's a run-time exception if we pass the `Set` directly here let ids = Array(ids) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 7a3282677..06b4ce7ae 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -15,15 +15,19 @@ import AppKit @MainActor 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 + @Published var settings: Settings // Enacapsulate Tunnel status here to make it easier for other components // to observe @Published private(set) var status: NEVPNStatus? + @Published private(set) var decision: UNAuthorizationStatus? + #if os(macOS) // Track whether our system extension has been installed (macOS) @Published private(set) var systemExtensionStatus: SystemExtensionStatus? @@ -39,6 +43,44 @@ public final class Store: ObservableObject { self.settings = Settings.defaultValue self.sessionNotification = SessionNotification() self.vpnConfigurationManager = VPNConfigurationManager() + + self.sessionNotification.signInHandler = { + Task { + do { try await WebAuthSession.signIn(store: self) } catch { Log.error(error) } + } + } + + Task { + // Load user's decision whether to allow / disallow notifications + self.decision = await self.sessionNotification.loadAuthorizationStatus() + + // Load VPN configuration and system extension status + do { + try await self.bindToVPNConfigurationUpdates() + let vpnConfigurationStatus = self.status + +#if os(macOS) + let systemExtensionStatus = try await self.checkedSystemExtensionStatus() + + if systemExtensionStatus != .installed + || vpnConfigurationStatus == .invalid { + + // Show the main Window if VPN permission needs to be granted + AppView.WindowDefinition.main.openWindow() + } else { + AppView.WindowDefinition.main.window()?.close() + } +#endif + + if vpnConfigurationStatus == .disconnected { + + // Try to connect on start + try self.vpnConfigurationManager.start() + } + } catch { + Log.error(error) + } + } } public func internetResourceEnabled() -> Bool { @@ -61,6 +103,17 @@ public final class Store: ObservableObject { self.actorName = actorName } + if status == .connected { + self.beginUpdatingResources { resourceList in + self.resourceList = resourceList + } + } + + if status == .disconnected { + self.endUpdatingResources() + self.resourceList = ResourceList.loading + } + #if os(macOS) // On macOS we must show notifications from the UI process. On iOS, we've already initiated the notification // from the tunnel process, because the UI process is not guaranteed to be alive. @@ -121,6 +174,10 @@ public final class Store: ObservableObject { try await bindToVPNConfigurationUpdates() } + func grantNotifications() async throws { + self.decision = try await sessionNotification.askUserForNotificationPermissions() + } + func authURL() -> URL? { return URL(string: settings.authBaseURL) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index fa4c691f8..a3ca8151b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -18,123 +18,11 @@ import UserNotifications /// - macOS only shows the WelcomeView on first launch (like Windows/Linux) /// - iOS shows the WelcomeView as it main view for launching auth -@MainActor -public class AppViewModel: ObservableObject { - let favorites: Favorites - let store: Store - let sessionNotification: SessionNotification - - @Published private(set) var status: NEVPNStatus? - @Published private(set) var decision: UNAuthorizationStatus? - - private var cancellables = Set() - - public init(favorites: Favorites, store: Store) { - self.favorites = favorites - self.store = store - self.sessionNotification = SessionNotification() - - self.sessionNotification.signInHandler = { - Task { - do { try await WebAuthSession.signIn(store: self.store) } catch { Log.error(error) } - } - } - - Task { - // Load user's decision whether to allow / disallow notifications - let decision = await self.sessionNotification.loadAuthorizationStatus() - updateNotificationDecision(to: decision) - - // Load VPN configuration and system extension status - do { - try await self.store.bindToVPNConfigurationUpdates() - let vpnConfigurationStatus = self.store.status - -#if os(macOS) - let systemExtensionStatus = try await self.store.checkedSystemExtensionStatus() - - if systemExtensionStatus != .installed - || vpnConfigurationStatus == .invalid { - - // Show the main Window if VPN permission needs to be granted - AppViewModel.WindowDefinition.main.openWindow() - } else { - AppViewModel.WindowDefinition.main.window()?.close() - } -#endif - - if vpnConfigurationStatus == .disconnected { - - // Try to connect on start - try self.store.vpnConfigurationManager.start() - } - } catch { - Log.error(error) - } - } - - store.$status - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] status in - guard let self = self else { return } - - self.status = status - }) - .store(in: &cancellables) - } - - func updateNotificationDecision(to newStatus: UNAuthorizationStatus) { - self.decision = newStatus - } -} - public struct AppView: View { - @ObservedObject var model: AppViewModel - - public init(model: AppViewModel) { - self.model = model - } - - @ViewBuilder - public var body: some View { -#if os(iOS) - switch (model.status, model.decision) { - case (nil, _), (_, nil): - ProgressView() - case (.invalid, _): - GrantVPNView(model: GrantVPNViewModel(store: model.store)) - case (_, .notDetermined): - GrantNotificationsView(model: GrantNotificationsViewModel( - sessionNotification: model.sessionNotification, - onDecisionChanged: { decision in - model.updateNotificationDecision(to: decision) - } - )) - case (.disconnected, _): - iOSNavigationView(model: model) { - WelcomeView(model: WelcomeViewModel(store: model.store)) - } - case (_, _): - iOSNavigationView(model: model) { - SessionView(model: SessionViewModel(favorites: model.favorites, store: model.store)) - } - } -#elseif os(macOS) - switch (model.store.systemExtensionStatus, model.status) { - case (nil, nil): - ProgressView() - case (.needsInstall, _), (_, .invalid): - GrantVPNView(model: GrantVPNViewModel(store: model.store)) - default: - FirstTimeView() - } -#endif - } -} + @EnvironmentObject var store: Store #if os(macOS) -public extension AppViewModel { - enum WindowDefinition: String, CaseIterable { + public enum WindowDefinition: String, CaseIterable { case main case settings @@ -164,5 +52,38 @@ public extension AppViewModel { } } } -} #endif + + public init() {} + + @ViewBuilder + public var body: some View { +#if os(iOS) + switch (store.status, store.decision) { + case (nil, _), (_, nil): + ProgressView() + case (.invalid, _): + GrantVPNView() + case (_, .notDetermined): + GrantNotificationsView() + case (.disconnected, _): + iOSNavigationView { + WelcomeView() + } + case (_, _): + iOSNavigationView { + SessionView() + } + } +#elseif os(macOS) + switch (store.systemExtensionStatus, store.status) { + case (nil, nil): + ProgressView() + case (.needsInstall, _), (_, .invalid): + GrantVPNView() + default: + FirstTimeView() + } +#endif + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/FirstTimeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/FirstTimeView.swift index eca2d78ab..22ffbb923 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/FirstTimeView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/FirstTimeView.swift @@ -31,13 +31,13 @@ struct FirstTimeView: View { Spacer() HStack { Button("Close this window") { - AppViewModel.WindowDefinition.main.window()?.close() + AppView.WindowDefinition.main.window()?.close() } .buttonStyle(.borderedProminent) .controlSize(.large) Button("Open menu") { menuBar.showMenu() - AppViewModel.WindowDefinition.main.window()?.close() + AppView.WindowDefinition.main.window()?.close() } .buttonStyle(.borderedProminent) .controlSize(.large) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantNotificationsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantNotificationsView.swift index b81db95e0..3f523b07e 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantNotificationsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantNotificationsView.swift @@ -9,39 +9,8 @@ import Foundation import SwiftUI import UserNotifications -@MainActor -public final class GrantNotificationsViewModel: ObservableObject { - private let sessionNotification: SessionNotification - private let onDecisionChanged: (UNAuthorizationStatus) async -> Void - - init( - sessionNotification: SessionNotification, - onDecisionChanged: @escaping (UNAuthorizationStatus) async -> Void - ) { - self.sessionNotification = sessionNotification - self.onDecisionChanged = onDecisionChanged - } - - func grantNotificationButtonTapped(errorHandler: GlobalErrorHandler) { - Task { - do { - let decision = try await sessionNotification.askUserForNotificationPermissions() - await onDecisionChanged(decision) - } catch { - Log.error(error) - - errorHandler.handle(ErrorAlert( - title: "Error granting notifications", - error: error - )) - } - } - - } -} - struct GrantNotificationsView: View { - @ObservedObject var model: GrantNotificationsViewModel + @EnvironmentObject var store: Store @EnvironmentObject var errorHandler: GlobalErrorHandler public var body: some View { @@ -66,7 +35,7 @@ struct GrantNotificationsView: View { .imageScale(.large) Spacer() Button("Grant Notification Permission") { - model.grantNotificationButtonTapped(errorHandler: errorHandler) + grantNotifications() } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -80,4 +49,19 @@ struct GrantNotificationsView: View { Spacer() }) } + + func grantNotifications () { + Task { + do { + try await store.grantNotifications() + } catch { + Log.error(error) + + errorHandler.handle(ErrorAlert( + title: "Error granting notifications", + error: error + )) + } + } + } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift index a5abf5358..7996da5b1 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GrantVPNView.swift @@ -8,82 +8,8 @@ import SwiftUI import Combine -@MainActor -final class GrantVPNViewModel: ObservableObject { - @Published var isInstalled: Bool = false - - private let store: Store - private var cancellables: Set = [] - - init(store: Store) { - self.store = store - -#if os(macOS) - store.$systemExtensionStatus - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] status in - self?.isInstalled = status == .installed - }).store(in: &cancellables) -#endif - } - -#if os(macOS) - func installSystemExtensionButtonTapped() { - Task { - do { - try await store.installSystemExtension() - - // The window has a tendency to go to the background after installing - // the system extension - NSApp.activate(ignoringOtherApps: true) - - } catch { - Log.error(error) - await macOSAlert.show(for: error) - } - } - } - - func grantPermissionButtonTapped() { - Log.log("\(#function)") - Task { - do { - try await store.grantVPNPermission() - - // The window has a tendency to go to the background after allowing the - // VPN configuration - NSApp.activate(ignoringOtherApps: true) - } catch { - Log.error(error) - await macOSAlert.show(for: error) - } - } - } -#endif - -#if os(iOS) - func grantPermissionButtonTapped(errorHandler: GlobalErrorHandler) { - Log.log("\(#function)") - Task { - do { - try await store.grantVPNPermission() - } catch { - Log.error(error) - - errorHandler.handle( - ErrorAlert( - title: "Error installing VPN configuration", - error: error - ) - ) - } - } - } -#endif -} - struct GrantVPNView: View { - @ObservedObject var model: GrantVPNViewModel + @EnvironmentObject var store: Store @EnvironmentObject var errorHandler: GlobalErrorHandler var body: some View { @@ -110,7 +36,7 @@ struct GrantVPNView: View { .imageScale(.large) Spacer() Button("Grant VPN Permission") { - model.grantPermissionButtonTapped(errorHandler: errorHandler) + grantVPNPermission() } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -146,7 +72,7 @@ struct GrantVPNView: View { VStack(alignment: .center) { Text("Step 1: Enable the system extension") .font(.title) - .strikethrough(model.isInstalled, color: .primary) + .strikethrough(isInstalled(), color: .primary) Text(""" 1. Click the "Enable System Extension" button below. 2. Click "Open System Settings" in the dialog that appears. @@ -155,11 +81,11 @@ struct GrantVPNView: View { """) .font(.body) .padding(.vertical, 10) - .opacity(model.isInstalled ? 0.5 : 1.0) + .opacity(isInstalled() ? 0.5 : 1.0) Spacer() Button( action: { - model.installSystemExtensionButtonTapped() + installSystemExtension() }, label: { Label("Enable System Extension", systemImage: "gearshape") @@ -167,7 +93,7 @@ struct GrantVPNView: View { ) .buttonStyle(.borderedProminent) .controlSize(.large) - .disabled(model.isInstalled) + .disabled(isInstalled()) } Spacer() VStack(alignment: .center) { @@ -182,7 +108,7 @@ struct GrantVPNView: View { Spacer() Button( action: { - model.grantPermissionButtonTapped() + grantVPNPermission() }, label: { Label("Grant VPN Permission", systemImage: "network.badge.shield.half.filled") @@ -190,8 +116,8 @@ struct GrantVPNView: View { ) .buttonStyle(.borderedProminent) .controlSize(.large) - .disabled(!model.isInstalled) - }.opacity(model.isInstalled ? 1.0 : 0.5) + .disabled(!isInstalled()) + }.opacity(isInstalled() ? 1.0 : 0.5) Spacer() } Spacer() @@ -201,4 +127,53 @@ struct GrantVPNView: View { Spacer() #endif } + +#if os(macOS) + func installSystemExtension() { + Task { + do { + try await store.installSystemExtension() + + // The window has a tendency to go to the background after installing + // the system extension + NSApp.activate(ignoringOtherApps: true) + } catch { + Log.error(error) + await macOSAlert.show(for: error) + } + } + } + + func grantVPNPermission() { + Task { + do { + try await store.grantVPNPermission() + } catch { + Log.error(error) + await macOSAlert.show(for: error) + } + } + } + + func isInstalled() -> Bool { + return store.systemExtensionStatus == .installed + } +#endif + +#if os(iOS) + func grantVPNPermission() { + Task { + do { + try await store.grantVPNPermission() + } catch { + Log.error(error) + + errorHandler.handle(ErrorAlert( + title: "Error granting VPN permission", + error: error + )) + } + } + } +#endif } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 3d4ff021d..d67cad0ed 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -30,12 +30,10 @@ public final class MenuBar: NSObject, ObservableObject { private var cancellables: Set = [] - private var vpnStatus: NEVPNStatus? - private var updateChecker: UpdateChecker = UpdateChecker() private var updateMenuDisplayed: Bool = false - @ObservedObject var model: SessionViewModel + let store: Store private var signedOutIcon: NSImage? private var signedInConnectedIcon: NSImage? @@ -53,9 +51,9 @@ public final class MenuBar: NSObject, ObservableObject { private var connectingAnimationImageIndex: Int = 0 private var connectingAnimationTimer: Timer? - public init(model: SessionViewModel) { + public init(store: Store) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - self.model = model + self.store = store self.signedOutIcon = NSImage(named: "MenuBarIconSignedOut") self.signedInConnectedIcon = NSImage(named: "MenuBarIconSignedInConnected") self.signedOutIconNotification = NSImage(named: "MenuBarIconSignedOutNotification") @@ -79,38 +77,43 @@ public final class MenuBar: NSObject, ObservableObject { } private func setupObservers() { - model.favorites.$ids + // Favorites explicitly sends objectWillChange for lifecycle events. The instance in Store never changes. + store.favorites.objectWillChange .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] _ in guard let self = self else { return } + // When the user clicks to add or remove a favorite, the menu will close anyway, so just recreate the whole // menu. This avoids complex logic when changing in and out of the "nothing is favorited" special case. self.populateResourceMenus([]) - self.populateResourceMenus(model.resources.asArray()) + self.populateResourceMenus(store.resourceList.asArray()) }).store(in: &cancellables) - model.$resources + store.$resourceList .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] _ in guard let self = self else { return } - self.populateResourceMenus(model.resources.asArray()) + + self.populateResourceMenus(store.resourceList.asArray()) self.handleTunnelStatusOrResourcesChanged() }).store(in: &cancellables) - model.$status + store.$status .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] _ in guard let self = self else { return } - self.vpnStatus = model.status + self.updateStatusItemIcon() + self.handleTunnelStatusOrResourcesChanged() }).store(in: &cancellables) updateChecker.$updateAvailable .receive(on: DispatchQueue.main) - .sink(receiveValue: {[weak self] _ in - guard let self = self else { return } - self.updateStatusItemIcon() - self.refreshUpdateItem() + .sink(receiveValue: { [weak self] _ in + guard let self = self else { return } + + self.updateStatusItemIcon() + self.refreshUpdateItem() }).store(in: &cancellables) } @@ -243,7 +246,7 @@ public final class MenuBar: NSObject, ObservableObject { menu.addItem(resourcesUnavailableReasonMenuItem) menu.addItem(resourcesSeparatorMenuItem) - if !model.favorites.ids.isEmpty { + if !store.favorites.isEmpty() { menu.addItem(otherResourcesMenuItem) menu.addItem(otherResourcesSeparatorMenuItem) } @@ -279,7 +282,7 @@ public final class MenuBar: NSObject, ObservableObject { @objc private func signInButtonTapped() { Task { do { - try await WebAuthSession.signIn(store: self.model.store) + try await WebAuthSession.signIn(store: store) } catch { Log.error(error) await macOSAlert.show(for: error) @@ -288,7 +291,7 @@ public final class MenuBar: NSObject, ObservableObject { } @objc private func signOutButtonTapped() { - do { try self.model.store.signOut() } catch { Log.error(error) } + do { try store.signOut() } catch { Log.error(error) } } @objc private func grantPermissionMenuItemTapped() { @@ -298,8 +301,8 @@ public final class MenuBar: NSObject, ObservableObject { // our VPN configuration got removed. Since we don't know which, reinstall // the system extension here too just in case. It's a no-op if already // installed. - try await model.store.installSystemExtension() - try await model.store.grantVPNPermission() + try await store.installSystemExtension() + try await store.grantVPNPermission() } catch { Log.error(error) await macOSAlert.show(for: error) @@ -308,11 +311,11 @@ public final class MenuBar: NSObject, ObservableObject { } @objc private func settingsButtonTapped() { - AppViewModel.WindowDefinition.settings.openWindow() + AppView.WindowDefinition.settings.openWindow() } @objc private func adminPortalButtonTapped() { - guard let url = URL(string: model.store.settings.authBaseURL) + guard let url = URL(string: store.settings.authBaseURL) else { return } Task { await NSWorkspace.shared.openAsync(url) } @@ -341,7 +344,7 @@ public final class MenuBar: NSObject, ObservableObject { @objc private func quitButtonTapped() { Task { - do { try self.model.store.stop() } catch { Log.error(error) } + do { try store.stop() } catch { Log.error(error) } NSApp.terminate(self) } } @@ -375,8 +378,8 @@ public final class MenuBar: NSObject, ObservableObject { } private func updateStatusItemIcon() { - updateAnimation(status: vpnStatus) - statusItem.button?.image = getStatusIcon(status: vpnStatus, notification: updateChecker.updateAvailable) + updateAnimation(status: store.status) + statusItem.button?.image = getStatusIcon(status: store.status, notification: updateChecker.updateAvailable) } private func startConnectingAnimation() { @@ -402,9 +405,9 @@ public final class MenuBar: NSObject, ObservableObject { connectingAnimationImageIndex = (connectingAnimationImageIndex + 1) % connectingAnimationImages.count } - private func updateSignInMenuItems(status: NEVPNStatus?) { + private func updateSignInMenuItems() { // Update "Sign In" / "Sign Out" menu items - switch status { + switch store.status { case nil: signInMenuItem.title = "Loading VPN configurations from system settings…" signInMenuItem.action = nil @@ -431,7 +434,7 @@ public final class MenuBar: NSObject, ObservableObject { signOutMenuItem.isHidden = true settingsMenuItem.target = self case .connected, .reasserting, .connecting: - let title = "Signed in as \(model.store.actorName ?? "Unknown User")" + let title = "Signed in as \(store.actorName ?? "Unknown User")" signInMenuItem.title = title signInMenuItem.target = nil signOutMenuItem.isHidden = false @@ -441,9 +444,9 @@ public final class MenuBar: NSObject, ObservableObject { } } - private func updateResourcesMenuItems(status: NEVPNStatus?, resources: ResourceList) { + private func updateResourcesMenuItems() { // Update resources "header" menu items - switch status { + switch store.status { case .connecting: resourcesTitleMenuItem.isHidden = true resourcesUnavailableMenuItem.isHidden = false @@ -455,7 +458,7 @@ public final class MenuBar: NSObject, ObservableObject { resourcesTitleMenuItem.isHidden = false resourcesUnavailableMenuItem.isHidden = true resourcesUnavailableReasonMenuItem.isHidden = true - resourcesTitleMenuItem.title = resourceMenuTitle(resources) + resourcesTitleMenuItem.title = resourceMenuTitle(store.resourceList) resourcesSeparatorMenuItem.isHidden = false case .reasserting: resourcesTitleMenuItem.isHidden = true @@ -486,14 +489,11 @@ public final class MenuBar: NSObject, ObservableObject { } private func handleTunnelStatusOrResourcesChanged() { - let resources = model.resources - let status = model.status - - updateSignInMenuItems(status: status) - updateResourcesMenuItems(status: status, resources: resources) + updateSignInMenuItems() + updateResourcesMenuItems() quitMenuItem.title = { - switch status { + switch store.status { case .connected, .connecting: return "Disconnect and Quit" default: @@ -517,14 +517,14 @@ public final class MenuBar: NSObject, ObservableObject { private func populateResourceMenus(_ newResources: [Resource]) { // If we have no favorites, then everything is a favorite - let hasAnyFavorites = newResources.contains { model.favorites.contains($0.id) } + let hasAnyFavorites = newResources.contains { store.favorites.contains($0.id) } let newFavorites = if hasAnyFavorites { - newResources.filter { model.favorites.contains($0.id) || $0.isInternetResource() } + newResources.filter { store.favorites.contains($0.id) || $0.isInternetResource() } } else { newResources } let newOthers: [Resource] = if hasAnyFavorites { - newResources.filter { !model.favorites.contains($0.id) && !$0.isInternetResource() } + newResources.filter { !store.favorites.contains($0.id) && !$0.isInternetResource() } } else { [] } @@ -538,7 +538,7 @@ public final class MenuBar: NSObject, ObservableObject { return false } - return wasInternetResourceEnabled != model.store.internetResourceEnabled() + return wasInternetResourceEnabled != store.internetResourceEnabled() } private func refreshUpdateItem() { @@ -571,7 +571,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownFavorites = newFavorites - wasInternetResourceEnabled = model.store.internetResourceEnabled() + wasInternetResourceEnabled = store.internetResourceEnabled() } private func populateOtherResourcesMenu(_ newOthers: [Resource]) { @@ -599,7 +599,7 @@ public final class MenuBar: NSObject, ObservableObject { } } lastShownOthers = newOthers - wasInternetResourceEnabled = model.store.internetResourceEnabled() + wasInternetResourceEnabled = store.internetResourceEnabled() } @@ -624,7 +624,7 @@ public final class MenuBar: NSObject, ObservableObject { } private func internetResourceTitle(resource: Resource) -> String { - let status = model.store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled + let status = store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled return status + " " + resource.name } @@ -647,7 +647,7 @@ public final class MenuBar: NSObject, ObservableObject { } private func internetResourceToggleTitle() -> String { - model.isInternetResourceEnabled() ? "Disable this resource" : "Enable this resource" + store.internetResourceEnabled() ? "Disable this resource" : "Enable this resource" } // TODO: Refactor this when refactoring for macOS 13 @@ -710,7 +710,7 @@ public final class MenuBar: NSObject, ObservableObject { let toggleFavoriteItem = NSMenuItem() - if model.favorites.contains(resource.id) { + if store.favorites.contains(resource.id) { toggleFavoriteItem.action = #selector(removeFavoriteTapped(_:)) toggleFavoriteItem.title = "Remove from favorites" toggleFavoriteItem.toolTip = "Click to remove this Resource from Favorites" @@ -805,7 +805,7 @@ public final class MenuBar: NSObject, ObservableObject { @objc private func internetResourceToggle(_ sender: NSMenuItem) { Task { do { - try await self.model.store.toggleInternetResource(enabled: !model.store.internetResourceEnabled()) + try await store.toggleInternetResource(enabled: !store.internetResourceEnabled()) } catch { Log.error(error) } @@ -837,9 +837,9 @@ public final class MenuBar: NSObject, ObservableObject { private func setFavorited(id: String, favorited: Bool) { if favorited { - model.favorites.add(id) + store.favorites.add(id) } else { - model.favorites.remove(id) + store.favorites.remove(id) } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift index ca7802bb8..60366302b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/ResourceView.swift @@ -14,16 +14,16 @@ private func copyToClipboard(_ value: String) { } struct ResourceView: View { - @ObservedObject var model: SessionViewModel + @EnvironmentObject var store: Store var resource: Resource @Environment(\.openURL) var openURL var body: some View { List { if resource.isInternetResource() { - InternetResourceHeader(model: model, resource: resource) + InternetResourceHeader(resource: resource) } else { - NonInternetResourceHeader(model: model, resource: resource) + NonInternetResourceHeader(resource: resource) } if let site = resource.sites.first { @@ -98,7 +98,7 @@ struct ResourceView: View { } struct NonInternetResourceHeader: View { - @ObservedObject var model: SessionViewModel + @EnvironmentObject var store: Store var resource: Resource @Environment(\.openURL) var openURL @@ -170,10 +170,10 @@ struct NonInternetResourceHeader: View { } } - if model.favorites.ids.contains(resource.id) { + if store.favorites.contains(resource.id) { Button( action: { - model.favorites.remove(resource.id) + store.favorites.remove(resource.id) }, label: { HStack { @@ -186,7 +186,7 @@ struct NonInternetResourceHeader: View { } else { Button( action: { - model.favorites.add(resource.id) + store.favorites.add(resource.id) }, label: { HStack { Image(systemName: "star.fill") @@ -201,7 +201,7 @@ struct NonInternetResourceHeader: View { } struct InternetResourceHeader: View { - @ObservedObject var model: SessionViewModel + @EnvironmentObject var store: Store var resource: Resource var body: some View { @@ -225,17 +225,17 @@ struct InternetResourceHeader: View { Text("All network traffic") } - ToggleInternetResourceButton(resource: resource, model: model) + ToggleInternetResourceButton(resource: resource) } } } struct ToggleInternetResourceButton: View { var resource: Resource - @ObservedObject var model: SessionViewModel + @EnvironmentObject var store: Store private func toggleResourceEnabledText() -> String { - if model.isInternetResourceEnabled() { + if store.internetResourceEnabled() { "Disable this resource" } else { "Enable this resource" @@ -247,7 +247,7 @@ struct ToggleInternetResourceButton: View { action: { Task { do { - try await model.store.toggleInternetResource(enabled: !model.isInternetResourceEnabled()) + try await store.toggleInternetResource(enabled: !store.internetResourceEnabled()) } catch { Log.error(error) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift index 3f27f34ff..ee1e773fc 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SessionView.swift @@ -9,96 +9,30 @@ import NetworkExtension import OSLog import SwiftUI -@MainActor -public final class SessionViewModel: ObservableObject { - @Published private(set) var actorName: String? - @Published private(set) var favorites: Favorites - @Published private(set) var resources: ResourceList = ResourceList.loading - @Published private(set) var status: NEVPNStatus? - - let store: Store - - private var cancellables: Set = [] - - public init(favorites: Favorites, store: Store) { - self.favorites = favorites - self.store = store - - favorites.$ids - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] _ in - guard let self = self else { return } - self.objectWillChange.send() - }) - .store(in: &cancellables) - - store.$actorName - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] actorName in - guard let self = self else { return } - - self.actorName = actorName - }) - .store(in: &cancellables) - - store.$status - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] status in - guard let self = self else { return } - self.status = status - - if status == .connected { - store.beginUpdatingResources { resources in - self.resources = resources - } - } else { - store.endUpdatingResources() - self.resources = ResourceList.loading - } - - }) - .store(in: &cancellables) - } - - public func isInternetResourceEnabled() -> Bool { - store.internetResourceEnabled() - } -} - #if os(iOS) @MainActor struct SessionView: View { - @ObservedObject var model: SessionViewModel + @EnvironmentObject var store: Store var body: some View { - switch model.status { + switch store.status { case .connected: - switch model.resources { + switch store.resourceList { case .loaded(let resources): if resources.isEmpty { Text("No Resources. Contact your admin to be granted access.") } else { List { - let hasAnyFavorites = resources.contains { model.favorites.contains($0.id) } - if hasAnyFavorites { + if !store.favorites.isEmpty() { Section("Favorites") { - ResourceSection( - resources: resources.filter { model.favorites.contains($0.id) }, - model: model - ) + ResourceSection(resources: favoriteResources()) } Section("Other Resources") { - ResourceSection( - resources: resources.filter { !model.favorites.contains($0.id) }, - model: model - ) + ResourceSection(resources: nonFavoriteResources()) } } else { - ResourceSection( - resources: resources, - model: model - ) + ResourceSection(resources: resources) } } .listStyle(GroupedListStyle()) @@ -122,14 +56,32 @@ struct SessionView: View { Text("Unknown status. Please report this and attach your logs.") } } + + func favoriteResources() -> [Resource] { + switch store.resourceList { + case .loaded(let resources): + return resources.filter { store.favorites.contains($0.id) } + default: + return [] + } + } + + func nonFavoriteResources() -> [Resource] { + switch store.resourceList { + case .loaded(let resources): + return resources.filter { !store.favorites.contains($0.id) } + default: + return [] + } + } } struct ResourceSection: View { let resources: [Resource] - @ObservedObject var model: SessionViewModel + @EnvironmentObject var store: Store private func internetResourceTitle(resource: Resource) -> String { - let status = model.store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled + let status = store.internetResourceEnabled() ? StatusSymbol.enabled : StatusSymbol.disabled return status + " " + resource.name } @@ -145,7 +97,7 @@ struct ResourceSection: View { var body: some View { ForEach(resources) { resource in HStack { - NavigationLink { ResourceView(model: model, resource: resource) } + NavigationLink { ResourceView(resource: resource) } label: { Text(resourceTitle(resource: resource)) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index 9eb42e27c..3e2c7d315 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -22,31 +22,559 @@ enum SettingsViewError: Error { } } -@MainActor -public final class SettingsViewModel: ObservableObject { - let store: Store +extension FileManager { + enum FileManagerError: Error { + case invalidURL(URL, Error) - @Published var settings: Settings - - private var cancellables = Set() - - public init(store: Store) { - self.store = store - self.settings = store.settings - - setupObservers() + var localizedDescription: String { + switch self { + case .invalidURL(let url, let error): + return "Unable to get resource value for '\(url)': \(error)" + } + } } - func setupObservers() { - // Load settings from saved VPN Configuration - store.$settings - .receive(on: DispatchQueue.main) - .sink { [weak self] settings in - guard let self = self else { return } + func forEachFileUnder( + _ dirURL: URL, + including resourceKeys: Set, + handler: (URL, URLResourceValues) -> Void + ) { + // Deep-traverses the directory at dirURL + guard + let enumerator = self.enumerator( + at: dirURL, + includingPropertiesForKeys: [URLResourceKey](resourceKeys), + options: [], + errorHandler: nil + ) + else { + return + } - self.settings = settings + for item in enumerator.enumerated() { + if Task.isCancelled { break } + guard let url = item.element as? URL else { continue } + do { + let resourceValues = try url.resourceValues(forKeys: resourceKeys) + handler(url, resourceValues) + } catch { + Log.error(FileManagerError.invalidURL(url, error)) } - .store(in: &cancellables) + } + } +} + +// TODO: Refactor body length +// swiftlint:disable:next type_body_length +public struct SettingsView: View { + @EnvironmentObject var store: Store + @Environment(\.dismiss) var dismiss + @State var settings = Settings.defaultValue + + enum ConfirmationAlertContinueAction: Int { + case none + case saveSettings + case saveAllSettingsAndDismiss + + func performAction(on view: SettingsView) { + switch self { + case .none: + break + case .saveSettings: + view.saveSettings() + case .saveAllSettingsAndDismiss: + view.saveAllSettingsAndDismiss() + } + } + } + + @State private var isCalculatingLogsSize = false + @State private var calculatedLogsSize = "Unknown" + @State private var isClearingLogs = false + @State private var isExportingLogs = false + @State private var isShowingConfirmationAlert = false + @State private var confirmationAlertContinueAction: ConfirmationAlertContinueAction = .none + + @State private var calculateLogSizeTask: Task<(), Never>? + + #if os(iOS) + @State private var logTempZipFileURL: URL? + @State private var isPresentingExportLogShareSheet = false + #endif + + struct PlaceholderText { + static let authBaseURL = "Admin portal base URL" + static let apiURL = "Control plane WebSocket URL" + static let logFilter = "RUST_LOG-style filter string" + } + + struct FootnoteText { + static let forAdvanced = try? AttributedString( + markdown: """ + **WARNING:** These settings are intended for internal debug purposes **only**. \ + Changing these will disrupt access to your Firezone resources. + """ + ) + } + + public init() {} + + public var body: some View { + #if os(iOS) + NavigationView { + TabView { + advancedTab + .tabItem { + Image(systemName: "slider.horizontal.3") + Text("Advanced") + } + .badge(settings.isValid ? nil : "!") + logsTab + .tabItem { + Image(systemName: "doc.text") + Text("Diagnostic Logs") + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + let action = ConfirmationAlertContinueAction.saveAllSettingsAndDismiss + if case .connected = store.status { + self.confirmationAlertContinueAction = action + self.isShowingConfirmationAlert = true + } else { + action.performAction(on: self) + } + } + .disabled( + (settings == store.settings || !settings.isValid) + ) + } + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + self.reloadSettings() + } + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + } + .alert( + "Saving settings will sign you out", + isPresented: $isShowingConfirmationAlert, + presenting: confirmationAlertContinueAction, + actions: { confirmationAlertContinueAction in + Button("Cancel", role: .cancel) { + // Nothing to do + } + Button("Continue") { + confirmationAlertContinueAction.performAction(on: self) + } + }, + message: { _ in + Text("Changing settings will sign you out and disconnect you from resources") + } + ) + .onAppear { + settings = store.settings + } + + #elseif os(macOS) + VStack { + TabView { + advancedTab + .tabItem { + Text("Advanced") + } + logsTab + .tabItem { + Text("Diagnostic Logs") + } + } + .padding(20) + } + .alert( + "Saving settings will sign you out", + isPresented: $isShowingConfirmationAlert, + presenting: confirmationAlertContinueAction, + actions: { confirmationAlertContinueAction in + Button("Cancel", role: .cancel) { + // Nothing to do + } + Button("Continue", role: .destructive) { + confirmationAlertContinueAction.performAction(on: self) + } + }, + message: { _ in + Text("Changing settings will sign you out and disconnect you from resources") + } + ) + .onAppear { + settings = store.settings + } + .onDisappear(perform: { self.reloadSettings() }) + #else + #error("Unsupported platform") + #endif + } + + private var advancedTab: some View { + #if os(macOS) + VStack { + Spacer() + HStack { + Spacer() + Form { + TextField( + "Auth Base URL:", + text: Binding( + get: { settings.authBaseURL }, + set: { settings.authBaseURL = $0 } + ), + prompt: Text(PlaceholderText.authBaseURL) + ) + + TextField( + "API URL:", + text: Binding( + get: { settings.apiURL }, + set: { settings.apiURL = $0 } + ), + prompt: Text(PlaceholderText.apiURL) + ) + + TextField( + "Log Filter:", + text: Binding( + get: { settings.logFilter }, + set: { settings.logFilter = $0 } + ), + prompt: Text(PlaceholderText.logFilter) + ) + + Text(FootnoteText.forAdvanced ?? "") + .foregroundStyle(.secondary) + + HStack(spacing: 30) { + Button( + "Apply", + action: { + let action = ConfirmationAlertContinueAction.saveSettings + if [.connected, .connecting, .reasserting].contains(store.status) { + self.confirmationAlertContinueAction = action + self.isShowingConfirmationAlert = true + } else { + action.performAction(on: self) + } + } + ) + .disabled(settings == store.settings || !store.settings.isValid) + + Button( + "Reset to Defaults", + action: { + settings = Settings.defaultValue + store.favorites.reset() + } + ) + .disabled(store.favorites.isEmpty() && settings == Settings.defaultValue) + } + .padding(.top, 5) + } + .padding(10) + Spacer() + } + Spacer() + HStack { + Text("Build: \(BundleHelper.gitSha)") + .textSelection(.enabled) + .foregroundColor(.gray) + Spacer() + }.padding([.leading, .bottom], 20) + } + #elseif os(iOS) + VStack { + Form { + Section( + content: { + VStack(alignment: .leading, spacing: 2) { + Text("Auth Base URL") + .foregroundStyle(.secondary) + .font(.caption) + TextField( + PlaceholderText.authBaseURL, + text: Binding( + get: { settings.authBaseURL }, + set: { settings.authBaseURL = $0 } + ) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.done) + } + VStack(alignment: .leading, spacing: 2) { + Text("API URL") + .foregroundStyle(.secondary) + .font(.caption) + TextField( + PlaceholderText.apiURL, + text: Binding( + get: { settings.apiURL }, + set: { settings.apiURL = $0 } + ) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.done) + } + VStack(alignment: .leading, spacing: 2) { + Text("Log Filter") + .foregroundStyle(.secondary) + .font(.caption) + TextField( + PlaceholderText.logFilter, + text: Binding( + get: { settings.logFilter }, + set: { settings.logFilter = $0 } + ) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.done) + } + HStack { + Spacer() + Button( + "Reset to Defaults", + action: { + settings = Settings.defaultValue + store.favorites.reset() + } + ) + .disabled(store.favorites.isEmpty() && settings == Settings.defaultValue) + Spacer() + } + }, + header: { Text("Advanced Settings") }, + footer: { Text(FootnoteText.forAdvanced ?? "") } + ) + } + Spacer() + HStack { + Text("Build: \(BundleHelper.gitSha)") + .textSelection(.enabled) + .foregroundColor(.gray) + Spacer() + }.padding([.leading, .bottom], 20) + } + #endif + } + + private var logsTab: some View { + #if os(iOS) + VStack { + Form { + Section(header: Text("Logs")) { + LogDirectorySizeView( + isProcessing: $isCalculatingLogsSize, + sizeString: $calculatedLogsSize + ) + .onAppear { + self.refreshLogSize() + } + .onDisappear { + self.cancelRefreshLogSize() + } + HStack { + Spacer() + ButtonWithProgress( + systemImageName: "trash", + title: "Clear Log Directory", + isProcessing: $isClearingLogs, + action: { + self.clearLogFiles() + } + ) + Spacer() + } + } + Section { + HStack { + Spacer() + ButtonWithProgress( + systemImageName: "arrow.up.doc", + title: "Export Logs", + isProcessing: $isExportingLogs, + action: { + self.isExportingLogs = true + Task.detached(priority: .background) { + let archiveURL = LogExporter.tempFile() + try await LogExporter.export(to: archiveURL) + await MainActor.run { + self.logTempZipFileURL = archiveURL + self.isPresentingExportLogShareSheet = true + } + } + } + ) + .sheet(isPresented: $isPresentingExportLogShareSheet) { + if let logfileURL = self.logTempZipFileURL { + ShareSheetView( + localFileURL: logfileURL, + completionHandler: { + self.isPresentingExportLogShareSheet = false + self.isExportingLogs = false + self.logTempZipFileURL = nil + } + ) + .onDisappear { + self.isPresentingExportLogShareSheet = false + self.isExportingLogs = false + self.logTempZipFileURL = nil + } + } + } + Spacer() + } + } + } + } + #elseif os(macOS) + VStack { + VStack(alignment: .leading, spacing: 10) { + LogDirectorySizeView( + isProcessing: $isCalculatingLogsSize, + sizeString: $calculatedLogsSize + ) + .onAppear { + self.refreshLogSize() + } + .onDisappear { + self.cancelRefreshLogSize() + } + HStack(spacing: 30) { + ButtonWithProgress( + systemImageName: "trash", + title: "Clear Log Directory", + isProcessing: $isClearingLogs, + action: { + self.clearLogFiles() + } + ) + ButtonWithProgress( + systemImageName: "arrow.up.doc", + title: "Export Logs", + isProcessing: $isExportingLogs, + action: { + self.exportLogsWithSavePanelOnMac() + } + ) + } + } + } + #else + #error("Unsupported platform") + #endif + } + + func saveAllSettingsAndDismiss() { + saveSettings() + dismiss() + } + + func reloadSettings() { + settings = store.settings + dismiss() + } + + #if os(macOS) + func exportLogsWithSavePanelOnMac() { + self.isExportingLogs = true + + let savePanel = NSSavePanel() + savePanel.prompt = "Save" + savePanel.nameFieldLabel = "Save log archive to:" + let fileName = "firezone_logs_\(LogExporter.now()).aar" + + savePanel.nameFieldStringValue = fileName + + guard + let window = NSApp.windows.first(where: { + $0.identifier?.rawValue.hasPrefix("firezone-settings") ?? false + }) + else { + self.isExportingLogs = false + Log.log("Settings window not found. Can't show save panel.") + return + } + + savePanel.beginSheetModal(for: window) { response in + guard response == .OK else { + self.isExportingLogs = false + return + } + guard let destinationURL = savePanel.url else { + self.isExportingLogs = false + return + } + + Task { + do { + try await LogExporter.export( + to: destinationURL, + with: store.vpnConfigurationManager + ) + + window.contentViewController?.presentingViewController?.dismiss(self) + } catch { + if let error = error as? VPNConfigurationManagerError, + case VPNConfigurationManagerError.noIPCData = error { + Log.warning("\(#function): Error exporting logs: \(error). Is the XPC service running?") + } else { + Log.error(error) + } + + await macOSAlert.show(for: error) + } + + self.isExportingLogs = false + } + } + } + #endif + + func refreshLogSize() { + guard !self.isCalculatingLogsSize else { + return + } + self.isCalculatingLogsSize = true + self.calculateLogSizeTask = + Task.detached(priority: .background) { + let calculatedLogsSize = await calculateLogDirSize() + await MainActor.run { + self.calculatedLogsSize = calculatedLogsSize + self.isCalculatingLogsSize = false + self.calculateLogSizeTask = nil + } + } + } + + func cancelRefreshLogSize() { + self.calculateLogSizeTask?.cancel() + } + + func clearLogFiles() { + self.isClearingLogs = true + self.cancelRefreshLogSize() + Task.detached(priority: .background) { + do { try await clearAllLogs() } catch { Log.error(error) } + await MainActor.run { + self.isClearingLogs = false + if !self.isCalculatingLogsSize { + self.refreshLogSize() + } + } + } } func saveSettings() { @@ -125,568 +653,6 @@ public final class SettingsViewModel: ObservableObject { } } -extension FileManager { - enum FileManagerError: Error { - case invalidURL(URL, Error) - - var localizedDescription: String { - switch self { - case .invalidURL(let url, let error): - return "Unable to get resource value for '\(url)': \(error)" - } - } - } - - func forEachFileUnder( - _ dirURL: URL, - including resourceKeys: Set, - handler: (URL, URLResourceValues) -> Void - ) { - // Deep-traverses the directory at dirURL - guard - let enumerator = self.enumerator( - at: dirURL, - includingPropertiesForKeys: [URLResourceKey](resourceKeys), - options: [], - errorHandler: nil - ) - else { - return - } - - for item in enumerator.enumerated() { - if Task.isCancelled { break } - guard let url = item.element as? URL else { continue } - do { - let resourceValues = try url.resourceValues(forKeys: resourceKeys) - handler(url, resourceValues) - } catch { - Log.error(FileManagerError.invalidURL(url, error)) - } - } - } -} - -// TODO: Refactor body length -// swiftlint:disable:next type_body_length -public struct SettingsView: View { - @ObservedObject var favorites: Favorites - @ObservedObject var model: SettingsViewModel - @Environment(\.dismiss) var dismiss - - enum ConfirmationAlertContinueAction: Int { - case none - case saveSettings - case saveAllSettingsAndDismiss - - func performAction(on view: SettingsView) { - switch self { - case .none: - break - case .saveSettings: - view.saveSettings() - case .saveAllSettingsAndDismiss: - view.saveAllSettingsAndDismiss() - } - } - } - - @State private var isCalculatingLogsSize = false - @State private var calculatedLogsSize = "Unknown" - @State private var isClearingLogs = false - @State private var isExportingLogs = false - @State private var isShowingConfirmationAlert = false - @State private var confirmationAlertContinueAction: ConfirmationAlertContinueAction = .none - - @State private var calculateLogSizeTask: Task<(), Never>? - - #if os(iOS) - @State private var logTempZipFileURL: URL? - @State private var isPresentingExportLogShareSheet = false - #endif - - struct PlaceholderText { - static let authBaseURL = "Admin portal base URL" - static let apiURL = "Control plane WebSocket URL" - static let logFilter = "RUST_LOG-style filter string" - } - - struct FootnoteText { - static let forAdvanced = try? AttributedString( - markdown: """ - **WARNING:** These settings are intended for internal debug purposes **only**. \ - Changing these will disrupt access to your Firezone resources. - """ - ) - } - - public init(favorites: Favorites, model: SettingsViewModel) { - self.favorites = favorites - self.model = model - } - - public var body: some View { - #if os(iOS) - NavigationView { - TabView { - advancedTab - .tabItem { - Image(systemName: "slider.horizontal.3") - Text("Advanced") - } - .badge(model.settings.isValid ? nil : "!") - logsTab - .tabItem { - Image(systemName: "doc.text") - Text("Diagnostic Logs") - } - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { - let action = ConfirmationAlertContinueAction.saveAllSettingsAndDismiss - if case .connected = model.store.status { - self.confirmationAlertContinueAction = action - self.isShowingConfirmationAlert = true - } else { - action.performAction(on: self) - } - } - .disabled( - (model.settings == model.store.settings || !model.settings.isValid) - ) - } - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - self.reloadSettings() - } - } - } - .navigationTitle("Settings") - .navigationBarTitleDisplayMode(.inline) - } - .alert( - "Saving settings will sign you out", - isPresented: $isShowingConfirmationAlert, - presenting: confirmationAlertContinueAction, - actions: { confirmationAlertContinueAction in - Button("Cancel", role: .cancel) { - // Nothing to do - } - Button("Continue") { - confirmationAlertContinueAction.performAction(on: self) - } - }, - message: { _ in - Text("Changing settings will sign you out and disconnect you from resources") - } - ) - - #elseif os(macOS) - VStack { - TabView { - advancedTab - .tabItem { - Text("Advanced") - } - logsTab - .tabItem { - Text("Diagnostic Logs") - } - } - .padding(20) - } - .alert( - "Saving settings will sign you out", - isPresented: $isShowingConfirmationAlert, - presenting: confirmationAlertContinueAction, - actions: { confirmationAlertContinueAction in - Button("Cancel", role: .cancel) { - // Nothing to do - } - Button("Continue", role: .destructive) { - confirmationAlertContinueAction.performAction(on: self) - } - }, - message: { _ in - Text("Changing settings will sign you out and disconnect you from resources") - } - ) - .onDisappear(perform: { self.reloadSettings() }) - #else - #error("Unsupported platform") - #endif - } - - private var advancedTab: some View { - #if os(macOS) - VStack { - Spacer() - HStack { - Spacer() - Form { - TextField( - "Auth Base URL:", - text: Binding( - get: { model.settings.authBaseURL }, - set: { model.settings.authBaseURL = $0 } - ), - prompt: Text(PlaceholderText.authBaseURL) - ) - - TextField( - "API URL:", - text: Binding( - get: { model.settings.apiURL }, - set: { model.settings.apiURL = $0 } - ), - prompt: Text(PlaceholderText.apiURL) - ) - - TextField( - "Log Filter:", - text: Binding( - get: { model.settings.logFilter }, - set: { model.settings.logFilter = $0 } - ), - prompt: Text(PlaceholderText.logFilter) - ) - - Text(FootnoteText.forAdvanced ?? "") - .foregroundStyle(.secondary) - - HStack(spacing: 30) { - Button( - "Apply", - action: { - let action = ConfirmationAlertContinueAction.saveSettings - if [.connected, .connecting, .reasserting].contains(model.store.status) { - self.confirmationAlertContinueAction = action - self.isShowingConfirmationAlert = true - } else { - action.performAction(on: self) - } - } - ) - .disabled(model.settings == model.store.settings || !model.settings.isValid) - - Button( - "Reset to Defaults", - action: { - model.settings = Settings.defaultValue - favorites.reset() - } - ) - .disabled(favorites.ids.isEmpty && model.settings == Settings.defaultValue) - } - .padding(.top, 5) - } - .padding(10) - Spacer() - } - Spacer() - HStack { - Text("Build: \(BundleHelper.gitSha)") - .textSelection(.enabled) - .foregroundColor(.gray) - Spacer() - }.padding([.leading, .bottom], 20) - } - #elseif os(iOS) - VStack { - Form { - Section( - content: { - VStack(alignment: .leading, spacing: 2) { - Text("Auth Base URL") - .foregroundStyle(.secondary) - .font(.caption) - TextField( - PlaceholderText.authBaseURL, - text: Binding( - get: { model.settings.authBaseURL }, - set: { model.settings.authBaseURL = $0 } - ) - ) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .submitLabel(.done) - } - VStack(alignment: .leading, spacing: 2) { - Text("API URL") - .foregroundStyle(.secondary) - .font(.caption) - TextField( - PlaceholderText.apiURL, - text: Binding( - get: { model.settings.apiURL }, - set: { model.settings.apiURL = $0 } - ) - ) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .submitLabel(.done) - } - VStack(alignment: .leading, spacing: 2) { - Text("Log Filter") - .foregroundStyle(.secondary) - .font(.caption) - TextField( - PlaceholderText.logFilter, - text: Binding( - get: { model.settings.logFilter }, - set: { model.settings.logFilter = $0 } - ) - ) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .submitLabel(.done) - } - HStack { - Spacer() - Button( - "Reset to Defaults", - action: { - model.settings = Settings.defaultValue - } - ) - .disabled(model.settings == Settings.defaultValue) - Spacer() - } - }, - header: { Text("Advanced Settings") }, - footer: { Text(FootnoteText.forAdvanced ?? "") } - ) - } - Spacer() - HStack { - Text("Build: \(BundleHelper.gitSha)") - .textSelection(.enabled) - .foregroundColor(.gray) - Spacer() - }.padding([.leading, .bottom], 20) - } - #endif - } - - private var logsTab: some View { - #if os(iOS) - VStack { - Form { - Section(header: Text("Logs")) { - LogDirectorySizeView( - isProcessing: $isCalculatingLogsSize, - sizeString: $calculatedLogsSize - ) - .onAppear { - self.refreshLogSize() - } - .onDisappear { - self.cancelRefreshLogSize() - } - HStack { - Spacer() - ButtonWithProgress( - systemImageName: "trash", - title: "Clear Log Directory", - isProcessing: $isClearingLogs, - action: { - self.clearLogFiles() - } - ) - Spacer() - } - } - Section { - HStack { - Spacer() - ButtonWithProgress( - systemImageName: "arrow.up.doc", - title: "Export Logs", - isProcessing: $isExportingLogs, - action: { - self.isExportingLogs = true - Task.detached(priority: .background) { [weak model] in // self can't be weakly captured in views - guard model != nil else { return } - - let archiveURL = LogExporter.tempFile() - try await LogExporter.export(to: archiveURL) - await MainActor.run { - self.logTempZipFileURL = archiveURL - self.isPresentingExportLogShareSheet = true - } - } - } - ) - .sheet(isPresented: $isPresentingExportLogShareSheet) { - if let logfileURL = self.logTempZipFileURL { - ShareSheetView( - localFileURL: logfileURL, - completionHandler: { - self.isPresentingExportLogShareSheet = false - self.isExportingLogs = false - self.logTempZipFileURL = nil - } - ) - .onDisappear { - self.isPresentingExportLogShareSheet = false - self.isExportingLogs = false - self.logTempZipFileURL = nil - } - } - } - Spacer() - } - } - } - } - #elseif os(macOS) - VStack { - VStack(alignment: .leading, spacing: 10) { - LogDirectorySizeView( - isProcessing: $isCalculatingLogsSize, - sizeString: $calculatedLogsSize - ) - .onAppear { - self.refreshLogSize() - } - .onDisappear { - self.cancelRefreshLogSize() - } - HStack(spacing: 30) { - ButtonWithProgress( - systemImageName: "trash", - title: "Clear Log Directory", - isProcessing: $isClearingLogs, - action: { - self.clearLogFiles() - } - ) - ButtonWithProgress( - systemImageName: "arrow.up.doc", - title: "Export Logs", - isProcessing: $isExportingLogs, - action: { - self.exportLogsWithSavePanelOnMac() - } - ) - } - } - } - #else - #error("Unsupported platform") - #endif - } - - func saveSettings() { - model.saveSettings() - } - - func saveAllSettingsAndDismiss() { - model.saveSettings() - dismiss() - } - - func reloadSettings() { - model.settings = model.store.settings - dismiss() - } - - #if os(macOS) - func exportLogsWithSavePanelOnMac() { - self.isExportingLogs = true - - let savePanel = NSSavePanel() - savePanel.prompt = "Save" - savePanel.nameFieldLabel = "Save log archive to:" - let fileName = "firezone_logs_\(LogExporter.now()).aar" - - savePanel.nameFieldStringValue = fileName - - guard - let window = NSApp.windows.first(where: { - $0.identifier?.rawValue.hasPrefix("firezone-settings") ?? false - }) - else { - self.isExportingLogs = false - Log.log("Settings window not found. Can't show save panel.") - return - } - - savePanel.beginSheetModal(for: window) { response in - guard response == .OK else { - self.isExportingLogs = false - return - } - guard let destinationURL = savePanel.url else { - self.isExportingLogs = false - return - } - - Task { - do { - try await LogExporter.export( - to: destinationURL, - with: model.store.vpnConfigurationManager - ) - - window.contentViewController?.presentingViewController?.dismiss(self) - } catch { - if let error = error as? VPNConfigurationManagerError, - case VPNConfigurationManagerError.noIPCData = error { - Log.warning("\(#function): Error exporting logs: \(error). Is the XPC service running?") - } else { - Log.error(error) - } - - await macOSAlert.show(for: error) - } - - self.isExportingLogs = false - } - } - } - #endif - - func refreshLogSize() { - guard !self.isCalculatingLogsSize else { - return - } - self.isCalculatingLogsSize = true - self.calculateLogSizeTask = - Task.detached(priority: .background) { [weak model] in // self can't be weakly captured in views - guard let model else { return } - - let calculatedLogsSize = await model.calculateLogDirSize() - await MainActor.run { - self.calculatedLogsSize = calculatedLogsSize - self.isCalculatingLogsSize = false - self.calculateLogSizeTask = nil - } - } - } - - func cancelRefreshLogSize() { - self.calculateLogSizeTask?.cancel() - } - - func clearLogFiles() { - self.isClearingLogs = true - self.cancelRefreshLogSize() - Task.detached(priority: .background) { [weak model] in // self can't be weakly captured in views - guard let model else { return } - - do { try await model.clearAllLogs() } catch { Log.error(error) } - await MainActor.run { - self.isClearingLogs = false - if !self.isCalculatingLogsSize { - self.refreshLogSize() - } - } - } - } -} - struct ButtonWithProgress: View { let systemImageName: String let title: String diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift index abb75889f..23621f5dc 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift @@ -8,18 +8,9 @@ import AuthenticationServices import Combine import SwiftUI -@MainActor -final class WelcomeViewModel: ObservableObject { - let store: Store - - init(store: Store) { - self.store = store - } -} - struct WelcomeView: View { @EnvironmentObject var errorHandler: GlobalErrorHandler - @ObservedObject var model: WelcomeViewModel + @EnvironmentObject var store: Store var body: some View { VStack( @@ -40,7 +31,7 @@ struct WelcomeView: View { Button("Sign in") { Task { do { - try await WebAuthSession.signIn(store: model.store) + try await WebAuthSession.signIn(store: store) } catch { Log.error(error) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift index 982b61503..cd380ec6b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift @@ -11,14 +11,13 @@ import SwiftUI #if os(iOS) struct iOSNavigationView: View { // swiftlint:disable:this type_name @State private var isSettingsPresented = false - @ObservedObject var model: AppViewModel + @EnvironmentObject var store: Store @Environment(\.openURL) var openURL @EnvironmentObject var errorHandler: GlobalErrorHandler let content: Content - init(model: AppViewModel, @ViewBuilder content: () -> Content) { - self.model = model + init(@ViewBuilder content: () -> Content) { self.content = content() } @@ -41,7 +40,7 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n ) } .sheet(isPresented: $isSettingsPresented) { - SettingsView(favorites: model.favorites, model: SettingsViewModel(store: model.store)) + SettingsView() } .navigationViewStyle(StackNavigationViewStyle()) } @@ -55,13 +54,13 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n Label("Settings", systemImage: "gear") } ) - .disabled(model.status == .invalid) + .disabled(store.status == .invalid) } private var authMenu: some View { Menu { - if model.status == .connected { - Text("Signed in as \(model.store.actorName ?? "Unknown user")") + if store.status == .connected { + Text("Signed in as \(store.actorName ?? "Unknown user")") Button( action: { signOutButtonTapped() @@ -73,20 +72,8 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n } else { Button( action: { - Task { - do { - try await WebAuthSession.signIn(store: model.store) - } catch { - Log.error(error) + signInButtonTapped() - self.errorHandler.handle( - ErrorAlert( - title: "Error signing in", - error: error - ) - ) - } - } }, label: { Label("Sign in", systemImage: "person.crop.circle.fill.badge.plus") @@ -115,8 +102,36 @@ struct iOSNavigationView: View { // swiftlint:disable:this type_n } } - private func signOutButtonTapped() { - do { try model.store.signOut() } catch { Log.error(error) } + func signInButtonTapped() { + Task { + do { + try await WebAuthSession.signIn(store: store) + } catch { + Log.error(error) + + self.errorHandler.handle( + ErrorAlert( + title: "Error signing in", + error: error + ) + ) + } + } + } + + func signOutButtonTapped() { + do { + try store.signOut() + } catch { + Log.error(error) + + self.errorHandler.handle( + ErrorAlert( + title: "Error signing out", + error: error + ) + ) + } } } #endif