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