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:
Roopesh Chander
2024-03-06 11:44:58 +05:30
committed by GitHub
parent 8360e3734b
commit 2003e3dd83
8 changed files with 330 additions and 42 deletions

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

@@ -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))"

View File

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

View File

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

View File

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