From baae3bd693d3ad0dbd2fff955f344a1aef0fd26c Mon Sep 17 00:00:00 2001 From: Roopesh Chander Date: Wed, 20 Dec 2023 10:54:49 +0530 Subject: [PATCH] Apple: UI asking user to grant VPN permissions (#2959) Expect this to fix #2850 and #2928. When the app detects that there are no tunnels configured, it shows a UI with a "Grant VPN Permission" button. On clicking that, the OS prompt asking to allow VPN is shown. --- .../Firezone/Application/FirezoneApp.swift | 67 ++++++-- .../FirezoneKit/Features/AppView.swift | 14 +- .../Features/AskPermissionView.swift | 151 ++++++++++++++++++ .../FirezoneKit/Features/AuthView.swift | 13 +- .../FirezoneKit/Features/MainView.swift | 30 ++-- .../FirezoneKit/Features/SettingsView.swift | 11 +- .../FirezoneKit/Features/WelcomeView.swift | 89 ++++------- .../Sources/FirezoneKit/Stores/AppStore.swift | 97 +++++++++-- .../FirezoneKit/Stores/AuthStore.swift | 26 ++- .../FirezoneKit/Stores/TunnelStore.swift | 62 +++---- .../Sources/FirezoneKit/Views/Alerts.swift | 20 --- .../Sources/FirezoneKit/Views/MenuBar.swift | 79 ++++----- 12 files changed, 421 insertions(+), 238 deletions(-) create mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift delete mode 100644 swift/apple/FirezoneKit/Sources/FirezoneKit/Views/Alerts.swift diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index 074e95746..698124f11 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -11,22 +11,54 @@ import SwiftUI struct FirezoneApp: App { #if os(macOS) @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject var askPermissionViewModel: AskPermissionViewModel #endif #if os(iOS) - @StateObject var model = AppViewModel() + @StateObject var appViewModel: AppViewModel #endif + @StateObject var appStore = AppStore() + + init() { + let appStore = AppStore() + self._appStore = StateObject(wrappedValue: appStore) + + #if os(macOS) + self._askPermissionViewModel = + StateObject(wrappedValue: AskPermissionViewModel(tunnelStore: appStore.tunnelStore)) + appDelegate.appStore = appStore + #elseif os(iOS) + self._appViewModel = + StateObject(wrappedValue: AppViewModel(appStore: appStore)) + #endif + + } + var body: some Scene { #if os(iOS) WindowGroup { - AppView(model: model) + AppView(model: appViewModel) } #else - WindowGroup("Settings", id: "firezone-settings") { - SettingsView(model: appDelegate.settingsViewModel) + WindowGroup( + "Firezone (VPN Permission)", + id: AppStore.WindowDefinition.askPermission.identifier + ) { + AskPermissionView(model: askPermissionViewModel) } - .handlesExternalEvents(matching: ["settings"]) + .handlesExternalEvents( + matching: [AppStore.WindowDefinition.askPermission.externalEventMatchString] + ) + WindowGroup( + "Settings", + id: AppStore.WindowDefinition.settings.identifier + ) { + SettingsView(model: appStore.settingsViewModel) + } + .handlesExternalEvents( + matching: [AppStore.WindowDefinition.settings.externalEventMatchString] + ) #endif } } @@ -34,15 +66,30 @@ struct FirezoneApp: App { #if os(macOS) @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - let settingsViewModel = SettingsViewModel() - private var menuBar: MenuBar! + private var isAppLaunched = false + private var menuBar: MenuBar? + + public var appStore: AppStore? { + didSet { + if self.isAppLaunched { + // This is not expected to happen because appStore + // should be set before the app finishes launching. + // This code is only a contingency. + if let appStore = self.appStore { + self.menuBar = MenuBar(appStore: appStore) + } + } + } + } func applicationDidFinishLaunching(_: Notification) { - menuBar = MenuBar(settingsViewModel: settingsViewModel) + self.isAppLaunched = true + if let appStore = self.appStore { + self.menuBar = MenuBar(appStore: appStore) + } // SwiftUI will show the first window group, so close it on launch - let window = NSApp.windows[0] - window.close() + _ = AppStore.WindowDefinition.allCases.map { $0.window()?.close() } } func applicationWillTerminate(_: Notification) {} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift index 025f9419a..2e522a42c 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AppView.swift @@ -15,13 +15,9 @@ import SwiftUINavigationCore public final class AppViewModel: ObservableObject { @Published var welcomeViewModel: WelcomeViewModel? - public init() { + public init(appStore: AppStore) { Task { - self.welcomeViewModel = WelcomeViewModel( - appStore: AppStore( - tunnelStore: TunnelStore.shared - ) - ) + self.welcomeViewModel = WelcomeViewModel(appStore: appStore) } } } @@ -42,10 +38,4 @@ import SwiftUINavigationCore } } } - - struct AppView_Previews: PreviewProvider { - static var previews: some View { - AppView(model: AppViewModel()) - } - } #endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift new file mode 100644 index 000000000..307045bf3 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift @@ -0,0 +1,151 @@ +// +// AskPermissionView.swift +// (c) 2023 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +import Combine +import Foundation +import SwiftUI + +@MainActor +public final class AskPermissionViewModel: ObservableObject { + public var tunnelStore: TunnelStore + + private var cancellables: Set = [] + + @Published var needsTunnelPermission = false { + didSet { + #if os(macOS) + Task { + await MainActor.run { + AppStore.WindowDefinition.askPermission.bringAlreadyOpenWindowFront() + } + } + #endif + } + } + + public init(tunnelStore: TunnelStore) { + self.tunnelStore = tunnelStore + + tunnelStore.$tunnelAuthStatus + .filter { $0.isInitialized } + .sink { [weak self] tunnelAuthStatus in + guard let self = self else { return } + + Task { + await MainActor.run { + if case .noTunnelFound = tunnelAuthStatus { + self.needsTunnelPermission = true + } else { + self.needsTunnelPermission = false + } + } + } + } + .store(in: &cancellables) + + } + + func grantPermissionButtonTapped() { + Task { + do { + try await self.tunnelStore.createTunnel() + } catch { + #if os(macOS) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { + AppStore.WindowDefinition.askPermission.bringAlreadyOpenWindowFront() + } + #endif + } + } + } + + #if os(macOS) + func closeAskPermissionWindow() { + AppStore.WindowDefinition.askPermission.window()?.close() + } + #endif +} + +public struct AskPermissionView: View { + @ObservedObject var model: AskPermissionViewModel + + public init(model: AskPermissionViewModel) { + self.model = model + } + + public var body: some View { + VStack( + alignment: .center, + content: { + Spacer() + Image("LogoText") + Spacer() + if $model.needsTunnelPermission.wrappedValue { + + #if os(macOS) + Text( + "Firezone requires your permission to create VPN tunnels.\nUntil it has that permission, all functionality will be disabled." + ) + .font(.body) + .multilineTextAlignment(.center) + #elseif os(iOS) + Text( + "Firezone requires your permission to create VPN tunnels. Until it has that permission, all functionality will be disabled." + ) + .font(.body) + .multilineTextAlignment(.center) + #endif + Spacer() + Button("Grant VPN Permission") { + model.grantPermissionButtonTapped() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + Spacer() + .frame(maxHeight: 20) + #if os(macOS) + Text( + "After clicking on the above button,\nclick on 'Allow' when prompted." + ) + .font(.caption) + .multilineTextAlignment(.center) + #elseif os(iOS) + Text( + "After tapping on the above button, tap on 'Allow' when prompted." + ) + .font(.caption) + .multilineTextAlignment(.center) + #endif + + } else { + + #if os(macOS) + Text( + "You can sign in to Firezone by clicking on the Firezone icon in the macOS menu bar.\nYou may now close this window." + ) + .font(.body) + .multilineTextAlignment(.center) + + Spacer() + Button("Close this Window") { + model.closeAskPermissionWindow() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + Spacer() + .frame(maxHeight: 20) + Text( + "Firezone will continue running after this window is closed.\nIt will be available from the macOS menu bar." + ) + .font(.caption) + .multilineTextAlignment(.center) + #endif + + } + Spacer() + }) + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift index 7690d483f..a8098850b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AuthView.swift @@ -12,12 +12,17 @@ import XCTestDynamicOverlay @MainActor final class AuthViewModel: ObservableObject { - @Dependency(\.authStore) private var authStore + + let authStore: AuthStore var settingsUndefined: () -> Void = unimplemented("\(AuthViewModel.self).settingsUndefined") private var cancellables = Set() + init(authStore: AuthStore) { + self.authStore = authStore + } + func signInButtonTapped() async { do { try await authStore.signIn() @@ -48,9 +53,3 @@ struct AuthView: View { }) } } - -struct AuthView_Previews: PreviewProvider { - static var previews: some View { - AuthView(model: AuthViewModel()) - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift index c0f9fdcde..6b28af597 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/MainView.swift @@ -29,26 +29,26 @@ import SwiftUI } private func setupObservers() { - appStore.auth.$loginStatus + appStore.authStore.$loginStatus .receive(on: mainQueue) .sink { [weak self] loginStatus in self?.loginStatus = loginStatus } .store(in: &cancellables) - appStore.tunnel.$status + appStore.tunnelStore.$status .receive(on: mainQueue) .sink { [weak self] status in self?.tunnelStatus = status if status == .connected { - self?.appStore.tunnel.beginUpdatingResources() + self?.appStore.tunnelStore.beginUpdatingResources() } else { - self?.appStore.tunnel.endUpdatingResources() + self?.appStore.tunnelStore.endUpdatingResources() } } .store(in: &cancellables) - appStore.tunnel.$resources + appStore.tunnelStore.$resources .receive(on: mainQueue) .sink { [weak self] resources in guard let self = self else { return } @@ -61,20 +61,20 @@ import SwiftUI func signOutButtonTapped() { Task { - await appStore.auth.signOut() + await appStore.authStore.signOut() } } func startTunnel() async { if case .signedIn = self.loginStatus { - appStore.auth.startTunnel() + appStore.authStore.startTunnel() } } func stopTunnel() { Task { do { - try await appStore.tunnel.stop() + try await appStore.tunnelStore.stop() } catch { logger.error("\(#function): Error stopping tunnel: \(error)") } @@ -112,6 +112,8 @@ import SwiftUI Text("Signed Out") case .uninitialized: Text("Initializing…") + case .needsTunnelCreationPermission: + Text("Requires VPN permission") } } } @@ -150,16 +152,4 @@ import SwiftUI pasteboard.string = resource.location } } - - struct MainView_Previews: PreviewProvider { - static var previews: some View { - MainView( - model: MainViewModel( - appStore: AppStore( - tunnelStore: TunnelStore.shared - ) - ) - ) - } - } #endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift index 2b87b36a5..42ca1b5dc 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/SettingsView.swift @@ -18,7 +18,7 @@ enum SettingsViewError: Error { public final class SettingsViewModel: ObservableObject { private let logger = Logger.make(for: SettingsViewModel.self) - @Dependency(\.authStore) private var authStore + let authStore: AuthStore var tunnelAuthStatus: TunnelAuthStatus { authStore.tunnelStore.tunnelAuthStatus @@ -29,7 +29,8 @@ public final class SettingsViewModel: ObservableObject { public var onSettingsSaved: () -> Void = unimplemented() private var cancellables = Set() - public init() { + public init(authStore: AuthStore) { + self.authStore = authStore advancedSettings = AdvancedSettings.defaultValue loadSettings() } @@ -827,9 +828,3 @@ struct FormTextField: View { } } #endif - -struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - SettingsView(model: SettingsViewModel()) - } -} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift index 508c5e3ec..329521545 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift @@ -17,23 +17,19 @@ import SwiftUINavigationCore private var cancellables = Set() - enum Destination { - case settings(SettingsViewModel) - case undefinedSettingsAlert(AlertState) - } - - enum UndefinedSettingsAlertAction { - case confirmDefineSettingsButtonTapped - } - enum State { + case uninitialized + case needsPermission(AskPermissionViewModel) case unauthenticated(AuthViewModel) case authenticated(MainViewModel) - } - @Published var destination: Destination? { - didSet { - bindDestination() + var shouldDisableSettings: Bool { + switch self { + case .uninitialized: return true + case .needsPermission: return true + case .unauthenticated: return false + case .authenticated: return false + } } } @@ -45,17 +41,19 @@ import SwiftUINavigationCore private let appStore: AppStore + let settingsViewModel: SettingsViewModel + @Published var isSettingsSheetPresented = false + init(appStore: AppStore) { self.appStore = appStore + self.settingsViewModel = appStore.settingsViewModel appStore.objectWillChange .receive(on: mainQueue) .sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) - defer { bindDestination() } - - appStore.auth.$loginStatus + appStore.authStore.$loginStatus .receive(on: mainQueue) .sink(receiveValue: { [weak self] loginStatus in guard let self else { @@ -65,45 +63,31 @@ import SwiftUINavigationCore switch loginStatus { case .signedIn: self.state = .authenticated(MainViewModel(appStore: self.appStore)) - default: - self.state = .unauthenticated(AuthViewModel()) + case .signedOut: + self.state = .unauthenticated(AuthViewModel(authStore: self.appStore.authStore)) + case .needsTunnelCreationPermission: + self.state = .needsPermission( + AskPermissionViewModel(tunnelStore: self.appStore.tunnelStore) + ) + case .uninitialized: + self.state = .uninitialized } }) .store(in: &cancellables) } func settingsButtonTapped() { - destination = .settings(SettingsViewModel()) - } - - func handleUndefinedSettingsAlertAction(_ action: UndefinedSettingsAlertAction) { - switch action { - case .confirmDefineSettingsButtonTapped: - destination = .settings(SettingsViewModel()) - } - } - - private func bindDestination() { - switch destination { - case .settings(let model): - model.onSettingsSaved = { [weak self] in - self?.destination = nil - self?.state = .unauthenticated(AuthViewModel()) - } - - case .undefinedSettingsAlert, .none: - break - } + isSettingsSheetPresented = true } private func bindState() { switch state { case .unauthenticated(let model): model.settingsUndefined = { [weak self] in - self?.destination = .undefinedSettingsAlert(.undefinedSettings) + self?.isSettingsSheetPresented = true } - case .authenticated, .none: + case .authenticated, .uninitialized, .needsPermission, .none: break } } @@ -116,6 +100,10 @@ import SwiftUINavigationCore NavigationView { Group { switch model.state { + case .uninitialized: + Image("LogoText") + case .needsPermission(let model): + AskPermissionView(model: model) case .unauthenticated(let model): AuthView(model: model) case .authenticated(let model): @@ -132,26 +120,13 @@ import SwiftUINavigationCore } label: { Label("Settings", systemImage: "gear") } + .disabled(model.state?.shouldDisableSettings ?? true) } } } - .sheet(unwrapping: $model.destination, case: /WelcomeViewModel.Destination.settings) { - $model in - SettingsView(model: model) + .sheet(isPresented: $model.isSettingsSheetPresented) { + SettingsView(model: model.settingsViewModel) } - .alert( - unwrapping: $model.destination, - case: /WelcomeViewModel.Destination.undefinedSettingsAlert, - action: model.handleUndefinedSettingsAlertAction - ) - } - } - - struct WelcomeView_Previews: PreviewProvider { - static var previews: some View { - WelcomeView( - model: WelcomeViewModel(appStore: AppStore(tunnelStore: TunnelStore.shared)) - ) } } #endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift index a7410e9fa..aec8d7567 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift @@ -8,35 +8,98 @@ import Combine import Dependencies import OSLog +#if os(macOS) + import AppKit +#endif + @MainActor -final class AppStore: ObservableObject { +public final class AppStore: ObservableObject { private let logger = Logger.make(for: AppStore.self) - @Dependency(\.authStore) var auth - @Dependency(\.mainQueue) var mainQueue + #if os(macOS) + public enum WindowDefinition: String, CaseIterable { + case askPermission = "ask-permission" + case settings = "settings" + + public var identifier: String { "firezone-\(rawValue)" } + public var externalEventMatchString: String { rawValue } + public var externalEventOpenURL: URL { URL(string: "firezone://\(rawValue)")! } + + @MainActor + public func bringAlreadyOpenWindowFront() { + if let window = NSApp.windows.first(where: { + $0.identifier?.rawValue.hasPrefix(identifier) ?? false + }) { + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(self) + } + } + + @MainActor + public func openWindow() { + if let window = NSApp.windows.first(where: { + $0.identifier?.rawValue.hasPrefix(identifier) ?? false + }) { + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(self) + } else { + NSWorkspace.shared.open(externalEventOpenURL) + } + } + + @MainActor + public func window() -> NSWindow? { + NSApp.windows.first { window in + if let windowId = window.identifier?.rawValue { + return windowId.hasPrefix(self.identifier) + } + return false + } + } + + public static func allIdentifiers() -> [String] { + AppStore.WindowDefinition.allCases.map { $0.identifier } + } + } + #endif + + public let authStore: AuthStore + public let tunnelStore: TunnelStore + public let settingsViewModel: SettingsViewModel - let tunnel: TunnelStore private var cancellables: Set = [] - init(tunnelStore: TunnelStore) { - tunnel = tunnelStore + public init() { + let tunnelStore = TunnelStore() + let authStore = AuthStore(tunnelStore: tunnelStore) + let settingsViewModel = SettingsViewModel(authStore: authStore) + + self.authStore = authStore + self.tunnelStore = tunnelStore + self.settingsViewModel = settingsViewModel + + #if os(macOS) + tunnelStore.$tunnelAuthStatus + .sink { tunnelAuthStatus in + + if case .noTunnelFound = tunnelAuthStatus { + Task { + await MainActor.run { + WindowDefinition.askPermission.openWindow() + } + } + } + } + .store(in: &cancellables) + #endif - Publishers.Merge( - auth.objectWillChange, - tunnel.objectWillChange - ) - .receive(on: mainQueue) - .sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) } private func signOutAndStopTunnel() { Task { do { - try await tunnel.stop() - await auth.signOut() + try await self.tunnelStore.stop() + await self.authStore.signOut() } catch { logger.error("\(#function): Error stopping tunnel: \(String(describing: error))") } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift index f4f29a3f1..97a83f72a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift @@ -10,25 +10,13 @@ import Foundation import NetworkExtension import OSLog -extension AuthStore: DependencyKey { - static var liveValue: AuthStore = .shared -} - -extension DependencyValues { - var authStore: AuthStore { - get { self[AuthStore.self] } - set { self[AuthStore.self] = newValue } - } -} - @MainActor -final class AuthStore: ObservableObject { +public final class AuthStore: ObservableObject { private let logger = Logger.make(for: AuthStore.self) - static let shared = AuthStore(tunnelStore: TunnelStore.shared) - enum LoginStatus: CustomStringConvertible { case uninitialized + case needsTunnelCreationPermission case signedOut case signedIn(actorName: String) @@ -36,6 +24,8 @@ final class AuthStore: ObservableObject { switch self { case .uninitialized: return "uninitialized" + case .needsTunnelCreationPermission: + return "needsTunnelCreationPermission" case .signedOut: return "signedOut" case .signedIn(let actorName): @@ -64,7 +54,7 @@ final class AuthStore: ObservableObject { private let reconnectDelaySecs = 1 private var reconnectionAttemptsRemaining = maxReconnectionAttemptCount - private init(tunnelStore: TunnelStore) { + init(tunnelStore: TunnelStore) { self.tunnelStore = tunnelStore self.loginStatus = .uninitialized @@ -113,8 +103,10 @@ final class AuthStore: ObservableObject { private func getLoginStatus(from tunnelAuthStatus: TunnelAuthStatus) async -> LoginStatus { switch tunnelAuthStatus { - case .tunnelUninitialized: + case .uninitialized: return .uninitialized + case .noTunnelFound: + return .needsTunnelCreationPermission case .signedOut: return .signedOut case .signedIn(let tunnelAuthBaseURL, let tokenReference): @@ -246,6 +238,8 @@ final class AuthStore: ObservableObject { try await tunnelStore.saveAuthStatus(.signedOut) } } + case .needsTunnelCreationPermission: + break case .uninitialized: break } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift index e5fd7a515..0352d5d6d 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/TunnelStore.swift @@ -23,13 +23,11 @@ public struct TunnelProviderKeys { public static let keyConnlibLogFilter = "connlibLogFilter" } -final class TunnelStore: ObservableObject { +public final class TunnelStore: ObservableObject { private static let logger = Logger.make(for: TunnelStore.self) - static let shared = TunnelStore() - @Published private var tunnel: NETunnelProviderManager? - @Published private(set) var tunnelAuthStatus: TunnelAuthStatus = .tunnelUninitialized + @Published private(set) var tunnelAuthStatus: TunnelAuthStatus = .uninitialized @Published private(set) var status: NEVPNStatus { didSet { TunnelStore.logger.info("status changed: \(self.status.description)") } @@ -46,9 +44,9 @@ final class TunnelStore: ObservableObject { private var stopTunnelContinuation: CheckedContinuation<(), Error>? private var cancellables = Set() - init() { + public init() { self.tunnel = nil - self.tunnelAuthStatus = .tunnelUninitialized + self.tunnelAuthStatus = .uninitialized self.status = .invalid Task { @@ -77,13 +75,7 @@ final class TunnelStore: ObservableObject { self.tunnelAuthStatus = tunnel.authStatus() self.status = tunnel.connection.status } else { - let tunnel = NETunnelProviderManager() - tunnel.localizedDescription = "Firezone" - tunnel.protocolConfiguration = basicProviderProtocol() - try await tunnel.saveToPreferences() - Self.logger.log("\(#function): Tunnel created") - self.tunnel = tunnel - self.tunnelAuthStatus = .signedOut + self.tunnelAuthStatus = .noTunnelFound } setupTunnelObservers() Self.logger.log("\(#function): TunnelStore initialized") @@ -92,10 +84,23 @@ final class TunnelStore: ObservableObject { } } + func createTunnel() async throws { + guard self.tunnel == nil else { + return + } + let tunnel = NETunnelProviderManager() + tunnel.localizedDescription = "Firezone" + tunnel.protocolConfiguration = basicProviderProtocol() + try await tunnel.saveToPreferences() + Self.logger.log("\(#function): Tunnel created") + self.tunnel = tunnel + self.tunnelAuthStatus = tunnel.authStatus() + } + func saveAuthStatus(_ tunnelAuthStatus: TunnelAuthStatus) async throws { Self.logger.log("TunnelStore.\(#function) \(tunnelAuthStatus, privacy: .public)") guard let tunnel = tunnel else { - fatalError("Tunnel not initialized yet") + fatalError("No tunnel yet. Can't save auth status.") } let tunnelStatus = tunnel.connection.status @@ -111,7 +116,7 @@ final class TunnelStore: ObservableObject { func saveAdvancedSettings(_ advancedSettings: AdvancedSettings) async throws { Self.logger.log("TunnelStore.\(#function) \(advancedSettings, privacy: .public)") guard let tunnel = tunnel else { - fatalError("Tunnel not initialized yet") + fatalError("No tunnel yet. Can't save advanced settings.") } let tunnelStatus = tunnel.connection.status @@ -126,7 +131,7 @@ final class TunnelStore: ObservableObject { func advancedSettings() -> AdvancedSettings? { guard let tunnel = tunnel else { - Self.logger.log("\(#function): Tunnel not initialized yet") + Self.logger.log("\(#function): No tunnel created yet") return nil } @@ -148,7 +153,7 @@ final class TunnelStore: ObservableObject { func start() async throws { guard let tunnel = tunnel else { - Self.logger.log("\(#function): TunnelStore is not initialized") + Self.logger.log("\(#function): No tunnel created yet") return } @@ -179,7 +184,7 @@ final class TunnelStore: ObservableObject { func stop() async throws { guard let tunnel = tunnel else { - Self.logger.log("\(#function): TunnelStore is not initialized") + Self.logger.log("\(#function): No tunnel created yet") return } @@ -201,7 +206,7 @@ final class TunnelStore: ObservableObject { func signOut() async throws -> Keychain.PersistentRef? { guard let tunnel = tunnel else { - Self.logger.log("\(#function): TunnelStore is not initialized") + Self.logger.log("\(#function): No tunnel created yet") return nil } @@ -249,7 +254,7 @@ final class TunnelStore: ObservableObject { private func updateResources() { guard let tunnel = tunnel else { - Self.logger.log("\(#function): TunnelStore is not initialized") + Self.logger.log("\(#function): No tunnel created yet") return } @@ -333,7 +338,7 @@ final class TunnelStore: ObservableObject { func removeProfile() async throws { TunnelStore.logger.trace("\(#function)") guard let tunnel = tunnel else { - Self.logger.log("\(#function): TunnelStore is not initialized") + Self.logger.log("\(#function): No tunnel created yet") return } @@ -342,21 +347,24 @@ final class TunnelStore: ObservableObject { } enum TunnelAuthStatus: Equatable, CustomStringConvertible { - case tunnelUninitialized + case uninitialized + case noTunnelFound case signedOut case signedIn(authBaseURL: URL, tokenReference: Data) var isInitialized: Bool { switch self { - case .tunnelUninitialized: return false + case .uninitialized: return false default: return true } } var description: String { switch self { - case .tunnelUninitialized: + case .uninitialized: return "tunnel uninitialized" + case .noTunnelFound: + return "no tunnel found" case .signedOut: return "signedOut" case .signedIn(let authBaseURL, _): @@ -413,9 +421,9 @@ extension NETunnelProviderManager { var providerConfig: [String: Any] = protocolConfiguration.providerConfiguration ?? [:] switch authStatus { - case .tunnelUninitialized: - protocolConfiguration.passwordReference = nil - break + case .uninitialized, .noTunnelFound: + return + case .signedOut: protocolConfiguration.passwordReference = nil break diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/Alerts.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/Alerts.swift deleted file mode 100644 index aecb242a8..000000000 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/Alerts.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Alerts.swift -// (c) 2023 Firezone, Inc. -// LICENSE: Apache-2.0 -// - -import SwiftUINavigationCore - -#if os(iOS) - extension AlertState where Action == WelcomeViewModel.UndefinedSettingsAlertAction { - static let undefinedSettings = AlertState( - title: TextState("No settings found."), - message: TextState("To sign in, you first need to configure portal settings."), - dismissButton: .default( - TextState("Define settings"), - action: .send(.confirmDefineSettingsButtonTapped) - ) - ) - } -#endif diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 007ec65fa..f0988d37c 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -16,12 +16,6 @@ let logger = Logger.make(for: MenuBar.self) @Dependency(\.mainQueue) private var mainQueue - private var appStore: AppStore? { - didSet { - setupObservers() - } - } - private var cancellables: Set = [] private var statusItem: NSStatusItem private var orderedResources: [DisplayableResources.Resource] = [] @@ -39,34 +33,30 @@ private var connectingAnimationImageIndex: Int = 0 private var connectingAnimationTimer: Timer? - let settingsViewModel: SettingsViewModel + private var appStore: AppStore + private var settingsViewModel: SettingsViewModel private var loginStatus: AuthStore.LoginStatus = .signedOut private var tunnelStatus: NEVPNStatus = .invalid - public init(settingsViewModel: SettingsViewModel) { - self.settingsViewModel = settingsViewModel + public init(appStore: AppStore) { + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - settingsViewModel.onSettingsSaved = { - // TODO: close settings window and sign in - } - - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + self.appStore = appStore + self.settingsViewModel = appStore.settingsViewModel super.init() createMenu() + setupObservers() if let button = statusItem.button { button.image = signedOutIcon } - Task { - self.appStore = AppStore(tunnelStore: TunnelStore.shared) - updateStatusItemIcon() - } + updateStatusItemIcon() } private func setupObservers() { - appStore?.auth.$loginStatus + appStore.authStore.$loginStatus .receive(on: mainQueue) .sink { [weak self] loginStatus in self?.loginStatus = loginStatus @@ -75,7 +65,7 @@ } .store(in: &cancellables) - appStore?.tunnel.$status + appStore.tunnelStore.$status .receive(on: mainQueue) .sink { [weak self] status in self?.tunnelStatus = status @@ -85,7 +75,7 @@ } .store(in: &cancellables) - appStore?.tunnel.$resources + appStore.tunnelStore.$resources .receive(on: mainQueue) .sink { [weak self] resources in guard let self = self else { return } @@ -147,7 +137,7 @@ menu, title: "Settings", action: #selector(settingsButtonTapped), - target: self + target: nil ) private lazy var quitMenuItem: NSMenuItem = { let menuItem = createMenuItem( @@ -200,15 +190,15 @@ } @objc private func reconnectButtonTapped() { - if case .signedIn = appStore?.auth.loginStatus { - appStore?.auth.startTunnel() + if case .signedIn = appStore.authStore.loginStatus { + appStore.authStore.startTunnel() } } @objc private func signInButtonTapped() { Task { do { - try await appStore?.auth.signIn() + try await appStore.authStore.signIn() } catch { logger.error("Error signing in: \(String(describing: error))") } @@ -217,12 +207,12 @@ @objc private func signOutButtonTapped() { Task { - await appStore?.auth.signOut() + await appStore.authStore.signOut() } } @objc private func settingsButtonTapped() { - openSettingsWindow() + AppStore.WindowDefinition.settings.openWindow() } @objc private func aboutButtonTapped() { @@ -233,7 +223,7 @@ @objc private func quitButtonTapped() { Task { do { - try await appStore?.tunnel.stop() + try await appStore.tunnelStore.stop() } catch { logger.error("\(#function): Error stopping tunnel: \(error)") } @@ -241,21 +231,10 @@ } } - private func openSettingsWindow() { - if let settingsWindow = NSApp.windows.first(where: { - $0.identifier?.rawValue.hasPrefix("firezone-settings") ?? false - }) { - NSApp.activate(ignoringOtherApps: true) - settingsWindow.makeKeyAndOrderFront(self) - } else { - NSWorkspace.shared.open(URL(string: "firezone://settings")!) - } - } - private func updateStatusItemIcon() { self.statusItem.button?.image = { switch self.loginStatus { - case .signedOut, .uninitialized: + case .signedOut, .uninitialized, .needsTunnelCreationPermission: return self.signedOutIcon case .signedIn: switch self.tunnelStatus { @@ -308,15 +287,23 @@ signInMenuItem.title = "Initializing" signInMenuItem.target = nil signOutMenuItem.isHidden = true + settingsMenuItem.target = nil + case .needsTunnelCreationPermission: + signInMenuItem.title = "Requires VPN permission" + signInMenuItem.target = nil + signOutMenuItem.isHidden = true + settingsMenuItem.target = nil case .signedOut: signInMenuItem.title = "Sign In" signInMenuItem.target = self signInMenuItem.isEnabled = true signOutMenuItem.isHidden = true + settingsMenuItem.target = self case .signedIn(let actorName): signInMenuItem.title = actorName.isEmpty ? "Signed in" : "Signed in as \(actorName)" signInMenuItem.target = nil signOutMenuItem.isHidden = false + settingsMenuItem.target = self } // Update resources "header" menu items switch (self.loginStatus, self.tunnelStatus) { @@ -325,6 +312,11 @@ resourcesUnavailableMenuItem.isHidden = true resourcesUnavailableReasonMenuItem.isHidden = true resourcesSeparatorMenuItem.isHidden = true + case (.needsTunnelCreationPermission, _): + resourcesTitleMenuItem.isHidden = true + resourcesUnavailableMenuItem.isHidden = true + resourcesUnavailableReasonMenuItem.isHidden = true + resourcesSeparatorMenuItem.isHidden = true case (.signedOut, _): resourcesTitleMenuItem.isHidden = true resourcesUnavailableMenuItem.isHidden = true @@ -378,12 +370,11 @@ } private func handleMenuVisibilityOrStatusChanged() { - guard let appStore = appStore else { return } - let status = appStore.tunnel.status + let status = appStore.tunnelStore.status if isMenuVisible && status == .connected { - appStore.tunnel.beginUpdatingResources() + appStore.tunnelStore.beginUpdatingResources() } else { - appStore.tunnel.endUpdatingResources() + appStore.tunnelStore.endUpdatingResources() } }