diff --git a/swift/apple/Firezone/Application/FirezoneApp.swift b/swift/apple/Firezone/Application/FirezoneApp.swift index 4d62d6540..9f67b0138 100644 --- a/swift/apple/Firezone/Application/FirezoneApp.swift +++ b/swift/apple/Firezone/Application/FirezoneApp.swift @@ -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 = diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift index 1df142e6b..d355b9b05 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/AskPermissionView.swift @@ -11,6 +11,7 @@ import SwiftUI @MainActor public final class AskPermissionViewModel: ObservableObject { public var tunnelStore: TunnelStore + private var sessionNotificationHelper: SessionNotificationHelper private var cancellables: Set = [] @@ -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) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift index 874369922..e1f04efb1 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Features/WelcomeView.swift @@ -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() { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SessionNotificationHelper.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SessionNotificationHelper.swift new file mode 100644 index 000000000..b90bb8a71 --- /dev/null +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/SessionNotificationHelper.swift @@ -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 diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/TunnelShutdownEvent.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/TunnelShutdownEvent.swift index ff83c981a..0d33a089f 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/TunnelShutdownEvent.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/TunnelShutdownEvent.swift @@ -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))" diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift index 523da12cd..d393c3819 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AppStore.swift @@ -66,7 +66,7 @@ public final class AppStore: ObservableObject { public let settingsViewModel: SettingsViewModel private var cancellables: Set = [] - let logger: AppLogger + public let logger: AppLogger public init() { let logger = AppLogger(process: .app, folderURL: SharedAccess.appLogFolderURL) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift index 358e2360b..2b017a8e5 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/AuthStore.swift @@ -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 } } diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 0964a1b2b..5cc8156e7 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -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 } }