mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ struct iOSNavigationView<Content: View>: 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<Content: View>: 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<Content: View>: 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user