feat(apple): Show UI alerts for sign in failures (#7838)

On Apple, we will silently fail if the tunnel fails to start. This adds
a simple `NSAlert` modal (macOS) or `Alert` popup (iOS) that must be
dismissed before continuing if the tunnel fails to come up, so that the
user has a chance of understanding why.

The vast majority of the time this fails due to DNS lookup errors while
starting connlib.

Related: #7004
Supersedes: #7814
This commit is contained in:
Jamil
2025-01-23 22:06:33 -08:00
committed by GitHub
parent 411c9b7899
commit f779fe9667
10 changed files with 150 additions and 47 deletions

View File

@@ -16,6 +16,7 @@ struct FirezoneApp: App {
@StateObject var favorites: Favorites @StateObject var favorites: Favorites
@StateObject var appViewModel: AppViewModel @StateObject var appViewModel: AppViewModel
@StateObject var store: Store @StateObject var store: Store
@StateObject private var errorHandler = GlobalErrorHandler()
init() { init() {
// Initialize Telemetry as early as possible // Initialize Telemetry as early as possible
@@ -35,7 +36,7 @@ struct FirezoneApp: App {
var body: some Scene { var body: some Scene {
#if os(iOS) #if os(iOS)
WindowGroup { WindowGroup {
AppView(model: appViewModel) AppView(model: appViewModel).environmentObject(errorHandler)
} }
#elseif os(macOS) #elseif os(macOS)
WindowGroup( WindowGroup(

View File

@@ -271,7 +271,7 @@ public class VPNConfigurationManager {
Telemetry.setEnvironmentOrClose(settings.apiURL) Telemetry.setEnvironmentOrClose(settings.apiURL)
} }
func start(token: String? = nil) { func start(token: String? = nil) throws {
var options: [String: NSObject] = [:] var options: [String: NSObject] = [:]
// Pass token if provided // Pass token if provided
@@ -285,11 +285,7 @@ public class VPNConfigurationManager {
options.merge(["id": id as NSObject]) { _, n in n } options.merge(["id": id as NSObject]) { _, n in n }
} }
do { try session()?.startTunnel(options: options)
try session()?.startTunnel(options: options)
} catch {
Log.error(error)
}
} }
func stop(clearToken: Bool = false) { func stop(clearToken: Bool = false) {

View File

@@ -9,10 +9,24 @@ import Foundation
enum AuthClientError: Error { enum AuthClientError: Error {
case invalidCallbackURL case invalidCallbackURL
case invalidStateReturnedInCallback(expected: String, got: String)
case authResponseError(Error)
case sessionFailure(Error)
case randomNumberGenerationFailure(errorStatus: Int32) 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 { struct AuthClient {

View File

@@ -8,14 +8,13 @@
import Foundation import Foundation
import AuthenticationServices import AuthenticationServices
/// Wraps the ASWebAuthenticationSession ordeal so it can be called from either /// Wraps the ASWebAuthenticationSession ordeal so it can be called from either
/// the AuthView (iOS) or the MenuBar (macOS) /// the AuthView (iOS) or the MenuBar (macOS)
@MainActor @MainActor
struct WebAuthSession { struct WebAuthSession {
private static let scheme = "firezone-fd0020211111" private static let scheme = "firezone-fd0020211111"
static func signIn(store: Store) { static func signIn(store: Store) async throws {
guard let authURL = store.authURL(), guard let authURL = store.authURL(),
let authClient = try? AuthClient(authURL: authURL), let authClient = try? AuthClient(authURL: authURL),
let url = try? authClient.build() let url = try? authClient.build()
@@ -23,32 +22,37 @@ struct WebAuthSession {
let anchor = PresentationAnchor() let anchor = PresentationAnchor()
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { returnedURL, error in let authResponse = try await withCheckedThrowingContinuation() { continuation in
guard error == nil, let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) {
let authResponse = try? authClient.response(url: returnedURL) returnedURL,
else { error in
// Can happen if the user closes the opened Webview without signing in
dump(error)
return
}
Task {
do { 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 { } 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 try await store.signIn(authResponse: authResponse)
session.presentationContextProvider = anchor
// load cookies
session.prefersEphemeralWebBrowserSession = false
// Start auth
session.start()
} }
} }

View File

@@ -55,7 +55,10 @@ public final class Store: ObservableObject {
private func initNotifications() { private func initNotifications() {
// Finish initializing notification binding // Finish initializing notification binding
sessionNotification.signInHandler = { sessionNotification.signInHandler = {
WebAuthSession.signIn(store: self) Task.detached {
do { try await WebAuthSession.signIn(store: self) }
catch { Log.error(error) }
}
} }
sessionNotification.$decision sessionNotification.$decision
@@ -173,14 +176,14 @@ public final class Store: ObservableObject {
return URL(string: settings.authBaseURL) return URL(string: settings.authBaseURL)
} }
func start(token: String? = nil) async throws { private func start(token: String? = nil) throws {
guard status == .disconnected guard status == .disconnected
else { else {
Log.log("\(#function): Already connected") Log.log("\(#function): Already connected")
return return
} }
self.vpnConfigurationManager.start(token: token) try self.vpnConfigurationManager.start(token: token)
} }
func stop(clearToken: Bool = false) { func stop(clearToken: Bool = false) {
@@ -198,7 +201,7 @@ public final class Store: ObservableObject {
try await self.vpnConfigurationManager.saveAuthResponse(authResponse) try await self.vpnConfigurationManager.saveAuthResponse(authResponse)
// Bring the tunnel up and send it a token to start // 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 { func signOut() async throws {

View File

@@ -55,7 +55,7 @@ public class AppViewModel: ObservableObject {
if vpnConfigurationStatus == .disconnected { if vpnConfigurationStatus == .disconnected {
// Try to connect on start // Try to connect on start
await self.store.vpnConfigurationManager.start() try await self.store.vpnConfigurationManager.start()
} }
} catch { } catch {
Log.error(error) Log.error(error)

View File

@@ -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
}
}

View File

@@ -261,7 +261,21 @@ public final class MenuBar: NSObject, ObservableObject {
} }
@objc private func signInButtonTapped() { @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() { @objc private func signOutButtonTapped() {

View File

@@ -15,13 +15,10 @@ final class WelcomeViewModel: ObservableObject {
init(store: Store) { init(store: Store) {
self.store = store self.store = store
} }
func signInButtonTapped() {
WebAuthSession.signIn(store: store)
}
} }
struct WelcomeView: View { struct WelcomeView: View {
@EnvironmentObject var errorHandler: GlobalErrorHandler
@ObservedObject var model: WelcomeViewModel @ObservedObject var model: WelcomeViewModel
var body: some View { var body: some View {
@@ -41,11 +38,25 @@ struct WelcomeView: View {
""").multilineTextAlignment(.center) """).multilineTextAlignment(.center)
.padding(.bottom, 10) .padding(.bottom, 10)
Button("Sign in") { 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) .buttonStyle(.borderedProminent)
.controlSize(.large) .controlSize(.large)
Spacer() Spacer()
}) }
)
} }
} }

View File

@@ -13,6 +13,7 @@ struct iOSNavigationView<Content: View>: View {
@State private var isSettingsPresented = false @State private var isSettingsPresented = false
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
@Environment(\.openURL) var openURL @Environment(\.openURL) var openURL
@EnvironmentObject var errorHandler: GlobalErrorHandler
let content: Content let content: Content
@@ -25,8 +26,19 @@ struct iOSNavigationView<Content: View>: View {
NavigationView { NavigationView {
content content
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: AuthMenu) .navigationBarItems(leading: AuthMenu, trailing: SettingsButton)
.navigationBarItems(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) { .sheet(isPresented: $isSettingsPresented) {
SettingsView(favorites: model.favorites, model: SettingsViewModel(store: model.store)) SettingsView(favorites: model.favorites, model: SettingsViewModel(store: model.store))
@@ -54,7 +66,22 @@ struct iOSNavigationView<Content: View>: View {
} }
} else { } else {
Button(action: { 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") Label("Sign in", systemImage: "person.crop.circle.fill.badge.plus")
} }