diff --git a/geteduroam/GeteduroamApp.swift b/geteduroam/GeteduroamApp.swift index b716e71..efbd33e 100644 --- a/geteduroam/GeteduroamApp.swift +++ b/geteduroam/GeteduroamApp.swift @@ -53,8 +53,6 @@ struct GeteduroamApp: App { @StateObject var theme = Theme.theme - var store: StoreOf
! - @Environment(\.openURL) var openURL init() { @@ -72,16 +70,13 @@ struct GeteduroamApp: App { #else let initialState = Main.State() #endif - - store = .init(initialState: initialState, reducer: { Main() }, withDependencies: { [appDelegate] in - $0.authClient = appDelegate - }) + appDelegate.createStore(initialState: initialState) } #if os(iOS) var body: some Scene { WindowGroup { - MainView(store: store) + MainView(store: appDelegate.store) .environmentObject(theme) } } @@ -89,7 +84,7 @@ struct GeteduroamApp: App { // On macOS 13 and up using Window and .defaultPosition and .defaultSize would be better, but can't use control flow statement with 'SceneBuilder' var body: some Scene { WindowGroup("geteduroam", id: "mainWindow") { - MainView(store: store) + MainView(store: appDelegate.store) .environmentObject(theme) .frame(minWidth: 300, idealWidth: 540, maxWidth: .infinity, minHeight: 460, idealHeight: 640, maxHeight: .infinity, alignment: .center) .onDisappear { diff --git a/geteduroam/GeteduroamPackage/Sources/AuthClient/GeteduroamAppDelegate.swift b/geteduroam/GeteduroamPackage/Sources/Main/GeteduroamAppDelegate.swift similarity index 71% rename from geteduroam/GeteduroamPackage/Sources/AuthClient/GeteduroamAppDelegate.swift rename to geteduroam/GeteduroamPackage/Sources/Main/GeteduroamAppDelegate.swift index 90fe894..696c675 100644 --- a/geteduroam/GeteduroamPackage/Sources/AuthClient/GeteduroamAppDelegate.swift +++ b/geteduroam/GeteduroamPackage/Sources/Main/GeteduroamAppDelegate.swift @@ -1,4 +1,6 @@ import AppAuth +import AuthClient +import ComposableArchitecture import Foundation public enum StartAuthError: Error { @@ -9,9 +11,30 @@ public enum StartAuthError: Error { #if os(iOS) public class GeteduroamAppDelegate: NSObject, UIApplicationDelegate, ObservableObject, AuthClient { + + public func createStore(initialState: Main.State) { + assert(store == nil, "Call this method only once") + store = .init( + initialState: initialState, + reducer: { + Main() + }, + withDependencies: { + $0.authClient = self + } + ) + } + + public private(set) var store: StoreOf
! + private var currentAuthorizationFlow: OIDExternalUserAgentSession? public func startAuth(request: OIDAuthorizationRequest) async throws -> OIDAuthState { + if UIApplication.shared.keyWindow == nil { + // Sleep a bit to wait for a window to be created, as we might get called right at startup + let duration = UInt64(2 * 1_000_000_000) + try await Task.sleep(nanoseconds: duration) + } guard let window = UIApplication.shared.keyWindow else { throw StartAuthError.noWindow } @@ -31,6 +54,13 @@ public class GeteduroamAppDelegate: NSObject, UIApplicationDelegate, ObservableO } } + public func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: + [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + store.send(.applicationDidFinishLaunching) + return true + } + public func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { // Sends the URL to the current authorization flow (if any) which will process it if it relates to an authorization response. if let currentAuthorizationFlow, currentAuthorizationFlow.resumeExternalUserAgentFlow(with: url) { @@ -60,6 +90,22 @@ extension UIApplication { } #elseif os(macOS) public class GeteduroamAppDelegate: NSObject, NSApplicationDelegate, ObservableObject, AuthClient { + + public func createStore(initialState: Main.State) { + assert(store == nil, "Call this method only once") + store = .init( + initialState: initialState, + reducer: { + Main() + }, + withDependencies: { + $0.authClient = self + } + ) + } + + public private(set) var store: StoreOf
! + private var currentAuthorizationFlow: OIDExternalUserAgentSession? public func startAuth(request: OIDAuthorizationRequest) async throws -> OIDAuthState { diff --git a/geteduroam/GeteduroamPackage/Sources/Main/MainFeature.swift b/geteduroam/GeteduroamPackage/Sources/Main/MainFeature.swift index 18ef9f3..a977f8e 100644 --- a/geteduroam/GeteduroamPackage/Sources/Main/MainFeature.swift +++ b/geteduroam/GeteduroamPackage/Sources/Main/MainFeature.swift @@ -5,6 +5,7 @@ import DiscoveryClient import Foundation import Models import NotificationClient +import OSLog @Reducer public struct Main: Reducer { @@ -15,6 +16,11 @@ public struct Main: Reducer { @Dependency(\.notificationClient) var notificationClient @Dependency(\.date.now) var now + public struct PendingRenewAction: Equatable { + let organizationId: String + let profileId: String + } + @ObservableState public struct State: Equatable { public init(searchQuery: String = "", organizations: IdentifiedArrayOf = .init(uniqueElements: []), loadingState: LoadingState = .initial, searchResults: IdentifiedArrayOf = .init(uniqueElements: []), destination: Destination.State? = nil) { @@ -37,6 +43,7 @@ public struct Main: Reducer { var isSearching: Bool = false var searchQuery: String var searchResults: IdentifiedArrayOf + fileprivate var pendingRenewAction: PendingRenewAction? var isConnecting: Bool { get { @@ -58,6 +65,7 @@ public struct Main: Reducer { } public enum Action: BindableAction { + case applicationDidFinishLaunching case binding(BindingAction) case destination(PresentationAction) case discoveryResponse(TaskResult) @@ -101,44 +109,51 @@ public struct Main: Reducer { public var body: some Reducer { BindingReducer() - Reduce { - state, - action in + Reduce { state, action in switch action { - case .onAppear, - .tryAgainTapped: - state.loadingState = .isLoading - return .merge( - .run { send in - for await event in notificationClient.delegate() { - switch event { - case .renewActionTriggered(organizationId: let organizationId, profileId: let profileId): - await send(.renewActionInReminderTapped(organizationId: organizationId, profileId: profileId)) - - case let .remindMeLaterActionTriggered(validUntil, organizationId, profileId): - guard validUntil.timeIntervalSince(now) > 0 else { - return + case .applicationDidFinishLaunching: + Logger.notifications.debug("Application did finish launching") + let delegate = notificationClient.delegate() + return .run { send in + await withThrowingTaskGroup(of: Void.self) { @MainActor group in + group.addTask { + for await event in delegate { + switch event { + case .renewActionTriggered(organizationId: let organizationId, profileId: let profileId): + await send(.renewActionInReminderTapped(organizationId: organizationId, profileId: profileId)) + + case let .remindMeLaterActionTriggered(validUntil, organizationId, profileId): + guard validUntil.timeIntervalSince(now) > 0 else { + return + } + try await notificationClient.scheduleRenewReminder(validUntil, organizationId, profileId) } - try await notificationClient.scheduleRenewReminder(validUntil, organizationId, profileId) } } - }, - .run { send in - await send(.discoveryResponse(TaskResult { - do { - let (value, _) = try await discoveryClient.decodedResponse(for: .discover, as: DiscoveryResponse.self) - cacheClient.cacheDiscovery(value) - return value - } catch { - let restoredValue = try cacheClient.restoreDiscovery() - return restoredValue - } - })) - }) + } + } + + case .onAppear, .tryAgainTapped: + state.loadingState = .isLoading + return .run { send in + await send(.discoveryResponse(TaskResult { + do { + let (value, _) = try await discoveryClient.decodedResponse(for: .discover, as: DiscoveryResponse.self) + cacheClient.cacheDiscovery(value) + return value + } catch { + let restoredValue = try cacheClient.restoreDiscovery() + return restoredValue + } + })) + } case let .discoveryResponse(.success(response)): state.loadingState = .success state.organizations = .init(uniqueElements: response.content.organizations) + if let pendingRenewAction = state.pendingRenewAction { + return .send(.renewActionInReminderTapped(organizationId: pendingRenewAction.organizationId, profileId: pendingRenewAction.profileId)) + } return .none case let .discoveryResponse(.failure(error)): @@ -155,20 +170,29 @@ public struct Main: Reducer { state.destination = .alert(alert) return .none - case let .renewActionInReminderTapped(organizationId, profile): - if let organization = state.organizations[id: organizationId] { - state.destination = .connect(.init(organization: organization, selectedProfileId: profile, autoConnectOnAppear: true)) - } else { - let alert = AlertState(title: { - TextState(NSLocalizedString("Unknown organization", bundle: .module, comment: "Title when user asked to renew but the organization could not be found")) - }, actions: { - ButtonState(role: .cancel, action: .send(.okButtonTapped)) { - TextState(NSLocalizedString("OK", bundle: .module, comment: "")) - } - }, message: { - TextState(NSLocalizedString("The organization is no longer listed.", bundle: .module, comment: "Message when user asked to renew but the organization could not be found")) - }) - state.destination = .alert(alert) + case let .renewActionInReminderTapped(organizationId, profileId): + switch state.loadingState { + case .initial, .isLoading: + // Perform action when organizations are known + state.pendingRenewAction = PendingRenewAction(organizationId: organizationId, profileId: profileId) + + case .success, .failure: + state.pendingRenewAction = nil + + if let organization = state.organizations[id: organizationId] { + state.destination = .connect(.init(organization: organization, selectedProfileId: profileId, autoConnectOnAppear: true)) + } else { + let alert = AlertState(title: { + TextState(NSLocalizedString("Unknown organization", bundle: .module, comment: "Title when user asked to renew but the organization could not be found")) + }, actions: { + ButtonState(role: .cancel, action: .send(.okButtonTapped)) { + TextState(NSLocalizedString("OK", bundle: .module, comment: "")) + } + }, message: { + TextState(NSLocalizedString("The organization is no longer listed.", bundle: .module, comment: "Message when user asked to renew but the organization could not be found")) + }) + state.destination = .alert(alert) + } } return .none diff --git a/geteduroam/GeteduroamPackage/Sources/NotificationClient/NotificationClient.swift b/geteduroam/GeteduroamPackage/Sources/NotificationClient/NotificationClient.swift index 228d5da..f7fafc4 100644 --- a/geteduroam/GeteduroamPackage/Sources/NotificationClient/NotificationClient.swift +++ b/geteduroam/GeteduroamPackage/Sources/NotificationClient/NotificationClient.swift @@ -45,7 +45,7 @@ extension NotificationClient { } extension Logger { - static var notifications = Logger(subsystem: Bundle.main.bundleIdentifier ?? "NotificationClient", category: "notifications") + public static var notifications = Logger(subsystem: Bundle.main.bundleIdentifier ?? "NotificationClient", category: "notifications") } extension NotificationClient { @@ -63,7 +63,7 @@ extension NotificationClient { guard granted else { return } // Declare custom actions: Renew Now | Remind Me Later - let renewNowAction = UNNotificationAction(identifier: .renewNowActionId, title: NSLocalizedString("Renew Now", bundle: .module, comment: "Renew Now"), options: [.authenticationRequired], icon: UNNotificationActionIcon(systemImageName: "arrow.triangle.2.circlepath")) + let renewNowAction = UNNotificationAction(identifier: .renewNowActionId, title: NSLocalizedString("Renew Now", bundle: .module, comment: "Renew Now"), options: [.authenticationRequired, .foreground], icon: UNNotificationActionIcon(systemImageName: "arrow.triangle.2.circlepath")) let remindMeAction = UNNotificationAction(identifier: .remindMeActionId, title: NSLocalizedString("Remind Me Later", bundle: .module, comment: "Remind Me Later"), options: [], icon: UNNotificationActionIcon(systemImageName: "alarm")) let willExpireCategory = UNNotificationCategory(identifier: .willExpireCategoryId, actions: [renewNowAction, remindMeAction], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("Renew your connection to extend your access.", bundle: .module, comment: "Renew your connection to extend your access."), categorySummaryFormat: nil, options: [.hiddenPreviewsShowTitle])