diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index 397e9af77..01984b63b 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -16,6 +16,7 @@ struct FirezoneApp: App { @StateObject var favorites: Favorites @StateObject var appViewModel: AppViewModel @StateObject var store: Store + @StateObject private var errorHandler = GlobalErrorHandler() init() { // Initialize Telemetry as early as possible @@ -35,7 +36,7 @@ struct FirezoneApp: App { var body: some Scene { #if os(iOS) WindowGroup { - AppView(model: appViewModel) + AppView(model: appViewModel).environmentObject(errorHandler) } #elseif os(macOS) WindowGroup( diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift index 831b5c064..081144cfa 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/VPNConfigurationManager.swift @@ -271,7 +271,7 @@ public class VPNConfigurationManager { Telemetry.setEnvironmentOrClose(settings.apiURL) } - func start(token: String? = nil) { + func start(token: String? = nil) throws { var options: [String: NSObject] = [:] // Pass token if provided @@ -285,11 +285,7 @@ public class VPNConfigurationManager { options.merge(["id": id as NSObject]) { _, n in n } } - do { - try session()?.startTunnel(options: options) - } catch { - Log.error(error) - } + try session()?.startTunnel(options: options) } func stop(clearToken: Bool = false) { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthClient.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthClient.swift index fd3e0bec3..9afaad6b1 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthClient.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/AuthClient.swift @@ -9,10 +9,24 @@ import Foundation enum AuthClientError: Error { case invalidCallbackURL - case invalidStateReturnedInCallback(expected: String, got: String) - case authResponseError(Error) - case sessionFailure(Error) case randomNumberGenerationFailure(errorStatus: Int32) + + var description: String { + switch self { + case .invalidCallbackURL: + return """ + Invalid callback URL. Please try signing in again. + If this issue persists, contact your administrator. + """ + case .randomNumberGenerationFailure(let errorStatus): + return """ + Could not generate secure sign in params. Please try signing in again. + If this issue persists, contact your administrator. + + Code: \(errorStatus) + """ + } + } } struct AuthClient { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift index 873a2bd66..0d160503e 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Models/WebAuthSession.swift @@ -8,14 +8,13 @@ import Foundation import AuthenticationServices - /// Wraps the ASWebAuthenticationSession ordeal so it can be called from either /// the AuthView (iOS) or the MenuBar (macOS) @MainActor struct WebAuthSession { private static let scheme = "firezone-fd0020211111" - static func signIn(store: Store) { + static func signIn(store: Store) async throws { guard let authURL = store.authURL(), let authClient = try? AuthClient(authURL: authURL), let url = try? authClient.build() @@ -23,32 +22,37 @@ struct WebAuthSession { let anchor = PresentationAnchor() - let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { returnedURL, error in - guard error == nil, - let authResponse = try? authClient.response(url: returnedURL) - else { - // Can happen if the user closes the opened Webview without signing in - dump(error) - return - } - - Task { + let authResponse = try await withCheckedThrowingContinuation() { continuation in + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { + returnedURL, + error in do { - try await store.signIn(authResponse: authResponse) + if let error = error as? ASWebAuthenticationSessionError, + error.code == .canceledLogin { + // User canceled sign in + } else if let error = error { + throw error + } + + let authResponse = try authClient.response(url: returnedURL) + + continuation.resume(returning: authResponse) } catch { - Log.error(error) + continuation.resume(throwing: error) } } + + // Apple weirdness, doesn't seem to be actually used in macOS + session.presentationContextProvider = anchor + + // load cookies + session.prefersEphemeralWebBrowserSession = false + + // Start auth + session.start() } - // Apple weirdness, doesn't seem to be actually used in macOS - session.presentationContextProvider = anchor - - // load cookies - session.prefersEphemeralWebBrowserSession = false - - // Start auth - session.start() + try await store.signIn(authResponse: authResponse) } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 5fd147efe..b8cc49c8b 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -55,7 +55,10 @@ public final class Store: ObservableObject { private func initNotifications() { // Finish initializing notification binding sessionNotification.signInHandler = { - WebAuthSession.signIn(store: self) + Task.detached { + do { try await WebAuthSession.signIn(store: self) } + catch { Log.error(error) } + } } sessionNotification.$decision @@ -173,14 +176,14 @@ public final class Store: ObservableObject { return URL(string: settings.authBaseURL) } - func start(token: String? = nil) async throws { + private func start(token: String? = nil) throws { guard status == .disconnected else { Log.log("\(#function): Already connected") return } - self.vpnConfigurationManager.start(token: token) + try self.vpnConfigurationManager.start(token: token) } func stop(clearToken: Bool = false) { @@ -198,7 +201,7 @@ public final class Store: ObservableObject { try await self.vpnConfigurationManager.saveAuthResponse(authResponse) // Bring the tunnel up and send it a token to start - self.vpnConfigurationManager.start(token: authResponse.token) + try self.vpnConfigurationManager.start(token: authResponse.token) } func signOut() async throws { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift index 5168c0e64..bae180880 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/AppView.swift @@ -55,7 +55,7 @@ public class AppViewModel: ObservableObject { if vpnConfigurationStatus == .disconnected { // Try to connect on start - await self.store.vpnConfigurationManager.start() + try await self.store.vpnConfigurationManager.start() } } catch { Log.error(error) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GlobalErrorHandler.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GlobalErrorHandler.swift new file mode 100644 index 000000000..625b48515 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/GlobalErrorHandler.swift @@ -0,0 +1,33 @@ +// +// GlobalErrorHandler.swift +// (c) 2024 Firezone, Inc. +// LICENSE: Apache-2.0 +// + +// A utility class for responding to errors raised in the view hierarchy. + +import SwiftUI + +public class ErrorAlert: Identifiable { + var title: String + var error: Error + + public init(title: String = "An error occurred", error: Error) { + self.title = title + self.error = error + } +} + +public class GlobalErrorHandler: ObservableObject { + @Published var currentAlert: ErrorAlert? + + public init() {} + + public func handle(_ errorAlert: ErrorAlert) { + currentAlert = errorAlert + } + + public func clear() { + currentAlert = nil + } +} diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift index 0ac3c49f5..f82252c2a 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/MenuBar.swift @@ -261,7 +261,21 @@ public final class MenuBar: NSObject, ObservableObject { } @objc private func signInButtonTapped() { - WebAuthSession.signIn(store: model.store) + Task.detached { + do { + try await WebAuthSession.signIn(store: self.model.store) + } catch { + Log.error(error) + + let alert = await NSAlert() + await MainActor.run { + alert.messageText = "Error signing in" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + let _ = alert.runModal() + } + } + } } @objc private func signOutButtonTapped() { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift index b374bc445..9ab5dfb44 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/WelcomeView.swift @@ -15,13 +15,10 @@ final class WelcomeViewModel: ObservableObject { init(store: Store) { self.store = store } - - func signInButtonTapped() { - WebAuthSession.signIn(store: store) - } } struct WelcomeView: View { + @EnvironmentObject var errorHandler: GlobalErrorHandler @ObservedObject var model: WelcomeViewModel var body: some View { @@ -41,11 +38,25 @@ struct WelcomeView: View { """).multilineTextAlignment(.center) .padding(.bottom, 10) Button("Sign in") { - model.signInButtonTapped() + Task.detached { + do { + try await WebAuthSession.signIn(store: model.store) + } catch { + Log.error(error) + + await MainActor.run { + self.errorHandler.handle(ErrorAlert( + title: "Error signing in", + error: error + )) + } + } + } } .buttonStyle(.borderedProminent) .controlSize(.large) Spacer() - }) + } + ) } } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift index 77f42b377..d702803a0 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/iOSNavigationView.swift @@ -13,6 +13,7 @@ struct iOSNavigationView: View { @State private var isSettingsPresented = false @ObservedObject var model: AppViewModel @Environment(\.openURL) var openURL + @EnvironmentObject var errorHandler: GlobalErrorHandler let content: Content @@ -25,8 +26,19 @@ struct iOSNavigationView: View { NavigationView { content .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(leading: AuthMenu) - .navigationBarItems(trailing: SettingsButton) + .navigationBarItems(leading: AuthMenu, trailing: SettingsButton) + .alert( + item: $errorHandler.currentAlert, + content: { alert in + Alert( + title: Text(alert.title), + message: Text(alert.error.localizedDescription), + dismissButton: .default(Text("OK")) { + errorHandler.clear() + } + ) + } + ) } .sheet(isPresented: $isSettingsPresented) { SettingsView(favorites: model.favorites, model: SettingsViewModel(store: model.store)) @@ -54,7 +66,22 @@ struct iOSNavigationView: View { } } else { Button(action: { - WebAuthSession.signIn(store: model.store) + Task.detached { + do { + try await WebAuthSession.signIn(store: model.store) + } catch { + Log.error(error) + + await MainActor.run { + self.errorHandler.handle( + ErrorAlert( + title: "Error signing in", + error: error + ) + ) + } + } + } }) { Label("Sign in", systemImage: "person.crop.circle.fill.badge.plus") }