mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(apple): UI notification for reauth (#3684)
This PR shows an alert (macOS) / local notification (iOS) when the user is signed out because of a tunnel error. Fixes the apple part of #3329.
This commit is contained in:
@@ -26,7 +26,12 @@ struct FirezoneApp: App {
|
||||
|
||||
#if os(macOS)
|
||||
self._askPermissionViewModel =
|
||||
StateObject(wrappedValue: AskPermissionViewModel(tunnelStore: appStore.tunnelStore))
|
||||
StateObject(
|
||||
wrappedValue: AskPermissionViewModel(
|
||||
tunnelStore: appStore.tunnelStore,
|
||||
sessionNotificationHelper: SessionNotificationHelper(logger: appStore.logger, authStore: appStore.authStore)
|
||||
)
|
||||
)
|
||||
appDelegate.appStore = appStore
|
||||
#elseif os(iOS)
|
||||
self._appViewModel =
|
||||
|
||||
@@ -11,6 +11,7 @@ import SwiftUI
|
||||
@MainActor
|
||||
public final class AskPermissionViewModel: ObservableObject {
|
||||
public var tunnelStore: TunnelStore
|
||||
private var sessionNotificationHelper: SessionNotificationHelper
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
@@ -26,8 +27,11 @@ public final class AskPermissionViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
public init(tunnelStore: TunnelStore) {
|
||||
@Published var needsNotificationDecision = false
|
||||
|
||||
public init(tunnelStore: TunnelStore, sessionNotificationHelper: SessionNotificationHelper) {
|
||||
self.tunnelStore = tunnelStore
|
||||
self.sessionNotificationHelper = sessionNotificationHelper
|
||||
|
||||
tunnelStore.$tunnelAuthStatus
|
||||
.filter { $0.isInitialized }
|
||||
@@ -46,6 +50,23 @@ public final class AskPermissionViewModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
sessionNotificationHelper.$notificationDecision
|
||||
.filter { $0.isInitialized }
|
||||
.sink { [weak self] notificationDecision in
|
||||
guard let self = self else { return }
|
||||
|
||||
Task {
|
||||
await MainActor.run {
|
||||
if case .notDetermined = notificationDecision {
|
||||
self.needsNotificationDecision = true
|
||||
} else {
|
||||
self.needsNotificationDecision = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
}
|
||||
|
||||
func grantPermissionButtonTapped() {
|
||||
@@ -62,6 +83,12 @@ public final class AskPermissionViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func grantNotificationButtonTapped() {
|
||||
self.sessionNotificationHelper.askUserForNotificationPermissions()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
func closeAskPermissionWindow() {
|
||||
AppStore.WindowDefinition.askPermission.window()?.close()
|
||||
@@ -101,6 +128,10 @@ public struct AskPermissionView: View {
|
||||
)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5))
|
||||
Spacer()
|
||||
Image(systemName: "network.badge.shield.half.filled")
|
||||
.imageScale(.large)
|
||||
#endif
|
||||
Spacer()
|
||||
Button("Grant VPN Permission") {
|
||||
@@ -118,12 +149,36 @@ public struct AskPermissionView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
#elseif os(iOS)
|
||||
Text(
|
||||
"After tapping on the above button, tap on 'Allow' when prompted."
|
||||
"After tapping the above button, tap 'Allow' when prompted."
|
||||
)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
#endif
|
||||
} else if $model.needsNotificationDecision.wrappedValue {
|
||||
#if os(iOS)
|
||||
Text(
|
||||
"Firezone requires your permission to show local notifications when you need to sign in again."
|
||||
)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5))
|
||||
Spacer()
|
||||
Image(systemName: "bell")
|
||||
.imageScale(.large)
|
||||
Spacer()
|
||||
Button("Grant Notification Permission") {
|
||||
model.grantNotificationButtonTapped()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
Spacer()
|
||||
.frame(maxHeight: 20)
|
||||
Text(
|
||||
"After tapping the above button, tap 'Allow' when prompted."
|
||||
)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
#endif
|
||||
|
||||
} else {
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
@@ -40,6 +40,7 @@ import SwiftUINavigationCore
|
||||
}
|
||||
|
||||
private let appStore: AppStore
|
||||
private let sessionNotificationHelper: SessionNotificationHelper
|
||||
|
||||
let settingsViewModel: SettingsViewModel
|
||||
@Published var isSettingsSheetPresented = false
|
||||
@@ -48,32 +49,40 @@ import SwiftUINavigationCore
|
||||
self.appStore = appStore
|
||||
self.settingsViewModel = appStore.settingsViewModel
|
||||
|
||||
let sessionNotificationHelper = SessionNotificationHelper(logger: appStore.logger, authStore: appStore.authStore)
|
||||
self.sessionNotificationHelper = sessionNotificationHelper
|
||||
|
||||
appStore.objectWillChange
|
||||
.receive(on: mainQueue)
|
||||
.sink { [weak self] in self?.objectWillChange.send() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
appStore.authStore.$loginStatus
|
||||
.receive(on: mainQueue)
|
||||
.sink(receiveValue: { [weak self] loginStatus in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch loginStatus {
|
||||
case .signedIn:
|
||||
self.state = .authenticated(MainViewModel(appStore: self.appStore))
|
||||
case .signedOut:
|
||||
self.state = .unauthenticated(AuthViewModel(authStore: self.appStore.authStore))
|
||||
case .needsTunnelCreationPermission:
|
||||
self.state = .needsPermission(
|
||||
AskPermissionViewModel(tunnelStore: self.appStore.tunnelStore)
|
||||
Publishers.CombineLatest(
|
||||
appStore.authStore.$loginStatus,
|
||||
sessionNotificationHelper.$notificationDecision
|
||||
)
|
||||
.receive(on: mainQueue)
|
||||
.sink(receiveValue: { [weak self] loginStatus, notificationDecision in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
switch (loginStatus, notificationDecision) {
|
||||
case (.uninitialized, _), (_, .uninitialized):
|
||||
self.state = .uninitialized
|
||||
case (.needsTunnelCreationPermission, _), (_, .notDetermined):
|
||||
self.state = .needsPermission(
|
||||
AskPermissionViewModel(
|
||||
tunnelStore: self.appStore.tunnelStore,
|
||||
sessionNotificationHelper: self.sessionNotificationHelper
|
||||
)
|
||||
case .uninitialized:
|
||||
self.state = .uninitialized
|
||||
}
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
)
|
||||
case (.signedOut, .determined):
|
||||
self.state = .unauthenticated(AuthViewModel(authStore: self.appStore.authStore))
|
||||
case (.signedIn, .determined):
|
||||
self.state = .authenticated(MainViewModel(appStore: self.appStore))
|
||||
}
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func settingsButtonTapped() {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// SessionNotificationHelper.swift
|
||||
// (c) 2024 Firezone, Inc.
|
||||
// LICENSE: Apache-2.0
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import UserNotifications
|
||||
#endif
|
||||
|
||||
|
||||
// SessionNotificationHelper helps with showing iOS local notifications
|
||||
// when the session ends.
|
||||
// In macOS, it helps with showing an alert when the session ends.
|
||||
|
||||
public enum NotificationIndentifier: String {
|
||||
case sessionEndedNotificationCategory
|
||||
case signInNotificationAction
|
||||
case dismissNotificationAction
|
||||
}
|
||||
|
||||
public class SessionNotificationHelper: NSObject {
|
||||
|
||||
enum NotificationDecision {
|
||||
case uninitialized
|
||||
case notDetermined
|
||||
case determined(isNotificationAllowed: Bool)
|
||||
|
||||
var isInitialized: Bool {
|
||||
switch self {
|
||||
case .uninitialized: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let logger: AppLogger
|
||||
private let authStore: AuthStore
|
||||
|
||||
@Published var notificationDecision: NotificationDecision = .uninitialized {
|
||||
didSet {
|
||||
self.logger.log(
|
||||
"SessionNotificationHelper: notificationDecision changed to \(notificationDecision)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public init(logger: AppLogger, authStore: AuthStore) {
|
||||
|
||||
self.logger = logger
|
||||
self.authStore = authStore
|
||||
|
||||
super.init()
|
||||
|
||||
#if os(iOS)
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.delegate = self
|
||||
|
||||
let signInAction = UNNotificationAction(
|
||||
identifier: NotificationIndentifier.signInNotificationAction.rawValue,
|
||||
title: "Sign In",
|
||||
options: [.authenticationRequired, .foreground])
|
||||
let dismissAction = UNNotificationAction(
|
||||
identifier: NotificationIndentifier.dismissNotificationAction.rawValue,
|
||||
title: "Dismiss",
|
||||
options: [])
|
||||
let notificationActions = [signInAction, dismissAction]
|
||||
let certificateExpiryCategory = UNNotificationCategory(
|
||||
identifier: NotificationIndentifier.sessionEndedNotificationCategory.rawValue,
|
||||
actions: notificationActions,
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "",
|
||||
options: [])
|
||||
|
||||
notificationCenter.setNotificationCategories([certificateExpiryCategory])
|
||||
notificationCenter.getNotificationSettings { notificationSettings in
|
||||
self.logger.log(
|
||||
"SessionNotificationHelper: getNotificationSettings returned. authorizationStatus is \(notificationSettings.authorizationStatus)"
|
||||
)
|
||||
switch notificationSettings.authorizationStatus {
|
||||
case .notDetermined:
|
||||
self.notificationDecision = .notDetermined
|
||||
case .authorized:
|
||||
self.notificationDecision = .determined(isNotificationAllowed: true)
|
||||
case .denied:
|
||||
self.notificationDecision = .determined(isNotificationAllowed: false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func askUserForNotificationPermissions() {
|
||||
guard case .notDetermined = self.notificationDecision else { return }
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.requestAuthorization(options: [.sound, .alert]) { isAuthorized, error in
|
||||
self.logger.log(
|
||||
"SessionNotificationHelper.askUserForNotificationPermissions: isAuthorized = \(isAuthorized)"
|
||||
)
|
||||
if let error = error {
|
||||
self.logger.log(
|
||||
"SessionNotificationHelper.askUserForNotificationPermissions: Error: \(error)"
|
||||
)
|
||||
}
|
||||
self.notificationDecision = .determined(isNotificationAllowed: isAuthorized)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
// In iOS, use User Notifications.
|
||||
// This gets called from the tunnel side.
|
||||
public static func showSignedOutNotificationiOS(logger: AppLogger) {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { notificationSettings in
|
||||
if notificationSettings.authorizationStatus == .authorized {
|
||||
logger.log(
|
||||
"Notifications are allowed. Alert style is \(notificationSettings.alertStyle.rawValue)"
|
||||
)
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Your Firezone session has ended"
|
||||
content.body = "Please sign in again to reconnect"
|
||||
content.categoryIdentifier = NotificationIndentifier.sessionEndedNotificationCategory.rawValue
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "FirezoneTunnelShutdown", content: content, trigger: trigger
|
||||
)
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
logger.error("showSignedOutNotificationiOS: Error requesting notification: \(error)")
|
||||
} else {
|
||||
logger.error("showSignedOutNotificationiOS: Successfully requested notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
// In macOS, use a Cocoa alert.
|
||||
// This gets called from the app side.
|
||||
static func showSignedOutAlertmacOS(logger: AppLogger, authStore: AuthStore) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Your Firezone session has ended"
|
||||
alert.informativeText = "Please sign in again to reconnect"
|
||||
alert.addButton(withTitle: "Sign In")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let response = alert.runModal()
|
||||
if response == NSApplication.ModalResponse.alertFirstButtonReturn {
|
||||
logger.log("SessionNotificationHelper: \(#function): 'Sign In' clicked in notification")
|
||||
Task {
|
||||
do {
|
||||
try await authStore.signIn()
|
||||
} catch {
|
||||
logger.error("Error signing in: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension SessionNotificationHelper: UNUserNotificationCenterDelegate {
|
||||
public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
self.logger.log("SessionNotificationHelper: \(#function): 'Sign In' clicked in notification")
|
||||
let actionId = response.actionIdentifier
|
||||
let categoryId = response.notification.request.content.categoryIdentifier
|
||||
if categoryId == NotificationIndentifier.sessionEndedNotificationCategory.rawValue,
|
||||
actionId == NotificationIndentifier.signInNotificationAction.rawValue {
|
||||
// User clicked on 'Sign In' in the notification
|
||||
Task {
|
||||
do {
|
||||
try await self.authStore.signIn()
|
||||
} catch {
|
||||
self.logger.error("Error signing in: \(error)")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -34,11 +34,30 @@ public struct TunnelShutdownEvent: Codable, CustomStringConvertible {
|
||||
case .invalidAdapterState: return "invalid adapter state"
|
||||
}
|
||||
}
|
||||
|
||||
public var action: Action {
|
||||
switch self {
|
||||
case .stopped(let reason):
|
||||
if reason == .userInitiated {
|
||||
return .signoutImmediatelySilently
|
||||
} else if reason == .userLogout || reason == .userSwitch {
|
||||
return .doNothing
|
||||
} else {
|
||||
return .retryThenSignout
|
||||
}
|
||||
case .networkSettingsApplyFailure, .invalidAdapterState:
|
||||
return .retryThenSignout
|
||||
case .connlibConnectFailure, .connlibDisconnected,
|
||||
.badTunnelConfiguration, .tokenNotFound:
|
||||
return .signoutImmediately
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum Action {
|
||||
case doNothing
|
||||
case signoutImmediately
|
||||
case signoutImmediatelySilently
|
||||
case retryThenSignout
|
||||
}
|
||||
|
||||
@@ -46,23 +65,7 @@ public struct TunnelShutdownEvent: Codable, CustomStringConvertible {
|
||||
public let errorMessage: String
|
||||
public let date: Date
|
||||
|
||||
public var action: Action {
|
||||
switch reason {
|
||||
case .stopped(let reason):
|
||||
if reason == .userInitiated {
|
||||
return .signoutImmediately
|
||||
} else if reason == .userLogout || reason == .userSwitch {
|
||||
return .doNothing
|
||||
} else {
|
||||
return .retryThenSignout
|
||||
}
|
||||
case .networkSettingsApplyFailure, .invalidAdapterState:
|
||||
return .retryThenSignout
|
||||
case .connlibConnectFailure, .connlibDisconnected,
|
||||
.badTunnelConfiguration, .tokenNotFound:
|
||||
return .signoutImmediately
|
||||
}
|
||||
}
|
||||
public var action: Action { reason.action }
|
||||
|
||||
public var description: String {
|
||||
"(\(reason)\(action == .signoutImmediately ? " (needs immediate signout)" : ""), error: '\(errorMessage)', date: \(date))"
|
||||
|
||||
@@ -66,7 +66,7 @@ public final class AppStore: ObservableObject {
|
||||
public let settingsViewModel: SettingsViewModel
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
let logger: AppLogger
|
||||
public let logger: AppLogger
|
||||
|
||||
public init() {
|
||||
let logger = AppLogger(process: .app, folderURL: SharedAccess.appLogFolderURL)
|
||||
|
||||
@@ -10,6 +10,10 @@ import Foundation
|
||||
import NetworkExtension
|
||||
import OSLog
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public final class AuthStore: ObservableObject {
|
||||
enum LoginStatus: CustomStringConvertible {
|
||||
@@ -202,6 +206,13 @@ public final class AuthStore: ObservableObject {
|
||||
Task {
|
||||
await self.signOut()
|
||||
}
|
||||
#if os(macOS)
|
||||
SessionNotificationHelper.showSignedOutAlertmacOS(logger: self.logger, authStore: self)
|
||||
#endif
|
||||
case .signoutImmediatelySilently:
|
||||
Task {
|
||||
await self.signOut()
|
||||
}
|
||||
case .retryThenSignout:
|
||||
self.retryStartTunnel()
|
||||
case .doNothing:
|
||||
@@ -230,6 +241,9 @@ public final class AuthStore: ObservableObject {
|
||||
Task {
|
||||
await self.signOut()
|
||||
}
|
||||
#if os(macOS)
|
||||
SessionNotificationHelper.showSignedOutAlertmacOS(logger: self.logger, authStore: self)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
|
||||
func handleTunnelShutdown(dueTo reason: TunnelShutdownEvent.Reason, errorMessage: String) {
|
||||
TunnelShutdownEvent.saveToDisk(reason: reason, errorMessage: errorMessage, logger: self.logger)
|
||||
|
||||
#if os(iOS)
|
||||
if reason.action == .signoutImmediately {
|
||||
SessionNotificationHelper.showSignedOutNotificationiOS(logger: self.logger)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user