mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-28 18:18:55 +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 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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
@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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user