From 211bb3f23a0ad2da1dc5e622a43058cf6eb10ea7 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Tue, 2 Dec 2025 13:26:09 -0500 Subject: [PATCH] BREAKING CHANGE: switch from reswift to swift concurrency for state management --- Package.resolved | 18 - Package.swift | 14 - .../Rownd/Models/AppleSignUpCoordinator.swift | 135 +++---- .../Automations/AutomationsCoordinator.swift | 39 +- Sources/Rownd/Models/Context/AppConfig.swift | 91 ++--- Sources/Rownd/Models/Context/AuthState.swift | 95 +---- Sources/Rownd/Models/Context/Context.swift | 15 +- .../Models/Context/ObservableRowndState.swift | 348 ++++++++++++++++ Sources/Rownd/Models/Context/Passkeys.swift | 118 ++---- .../Models/Context/ReSwiftObserver.swift | 318 ++++++++------- Sources/Rownd/Models/Context/RowndState.swift | 178 +-------- Sources/Rownd/Models/Context/SignIn.swift | 35 +- Sources/Rownd/Models/Context/StateActor.swift | 377 ++++++++++++++++++ Sources/Rownd/Models/Context/StateStore.swift | 345 ++++++++++++++++ Sources/Rownd/Models/Context/User.swift | 281 +++++-------- .../Models/GoogleSignInCoordinator.swift | 75 ++-- Sources/Rownd/Models/PasskeyCoordinator.swift | 20 +- Sources/Rownd/Rownd.swift | 95 +++-- .../AccountManager/AccountManagerView.swift | 12 +- .../HubWebView/HubWebViewController.swift | 64 ++- Sources/Rownd/framework/Authenticator.swift | 90 ++--- Sources/Rownd/framework/RowndEvent.swift | 19 +- Sources/Rownd/framework/SmartLinks.swift | 27 +- Sources/Rownd/framework/TimeManager.swift | 10 +- Tests/RowndTests/AuthTests.swift | 175 ++++---- .../AutomationsCoordinatorTests.swift | 9 +- Tests/RowndTests/ObservableStateTests.swift | 70 ++-- Tests/RowndTests/RowndTests.swift | 20 +- Tests/RowndTests/StateTests.swift | 76 ++-- .../RowndTests/SubscriberMutationTests.swift | 11 +- .../xcshareddata/swiftpm/Package.resolved | 18 - .../xcshareddata/swiftpm/Package.resolved | 18 - 32 files changed, 1917 insertions(+), 1299 deletions(-) create mode 100644 Sources/Rownd/Models/Context/ObservableRowndState.swift create mode 100644 Sources/Rownd/Models/Context/StateActor.swift create mode 100644 Sources/Rownd/Models/Context/StateStore.swift diff --git a/Package.resolved b/Package.resolved index 64ec2f8..2bb40fb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -91,24 +91,6 @@ "version": "0.20.0" } }, - { - "package": "ReSwift", - "repositoryURL": "https://github.com/ReSwift/ReSwift", - "state": { - "branch": null, - "revision": "96146a29f394ae4c79be025fcec194e5b0d9c3b6", - "version": "6.1.0" - } - }, - { - "package": "ReSwiftThunk", - "repositoryURL": "https://github.com/ReSwift/ReSwift-Thunk", - "state": { - "branch": null, - "revision": "5ee9816158a2115863b1458c66cf1be9e9143b39", - "version": "2.0.1" - } - }, { "package": "SwiftKeychainWrapper", "repositoryURL": "https://github.com/jrendel/SwiftKeychainWrapper", diff --git a/Package.swift b/Package.swift index 01194ed..690dd18 100644 --- a/Package.swift +++ b/Package.swift @@ -25,16 +25,6 @@ let package = Package( ], dependencies: [ - .package( - name: "ReSwift", - url: "https://github.com/ReSwift/ReSwift", - .upToNextMajor(from: "6.1.0") - ), - .package( - name: "ReSwiftThunk", - url: "https://github.com/ReSwift/ReSwift-Thunk", - .upToNextMajor(from: "2.0.0") - ), .package( name: "JWTDecode", url: "https://github.com/auth0/JWTDecode.swift", @@ -105,8 +95,6 @@ let package = Package( name: "Rownd", dependencies: [ "AnyCodable", - "ReSwift", - "ReSwiftThunk", "JWTDecode", "LBBottomSheet", "Gzip", @@ -134,8 +122,6 @@ let package = Package( "Mocker", "Mockingbird", "AnyCodable", - "ReSwift", - "ReSwiftThunk", "JWTDecode", "LBBottomSheet", "Gzip", diff --git a/Sources/Rownd/Models/AppleSignUpCoordinator.swift b/Sources/Rownd/Models/AppleSignUpCoordinator.swift index b538f81..60b8a44 100644 --- a/Sources/Rownd/Models/AppleSignUpCoordinator.swift +++ b/Sources/Rownd/Models/AppleSignUpCoordinator.swift @@ -5,12 +5,11 @@ // Created by Michael Murray on 7/17/22. // -import SwiftUI -import AuthenticationServices -import UIKit import AnyCodable -import ReSwiftThunk +import AuthenticationServices import Combine +import SwiftUI +import UIKit private let appleSignInDataKey = "userAppleSignInData" @@ -140,33 +139,34 @@ class AppleSignUpCoordinator: NSObject, ASAuthorizationControllerDelegate, ASAut // Prevent fast auth handshakes from feeling jarring to the user try await Task.sleep(nanoseconds: UInt64(2 * Double(NSEC_PER_SEC))) - DispatchQueue.main.async { - Context.currentContext.store.dispatch(Context.currentContext.store.state.auth.onReceiveAppleAuthTokens( - AuthState( - accessToken: tokenResponse?.accessToken, - refreshToken: tokenResponse?.refreshToken - ) - )) + // Handle auth tokens + let newAuthState = AuthState( + accessToken: tokenResponse?.accessToken, + refreshToken: tokenResponse?.refreshToken + ) + newAuthState.onReceiveAppleAuthTokens(newAuthState) + + await Context.currentContext.store.setLastSignInMethod(.apple) + + // Subscribe to auth state to update user data when valid + Context.currentContext.store.publisher(for: \.auth.isAccessTokenValid) + .filter { $0 } + .first() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateUserDataWithAppleData(fullName: fullName, email: email) + + RowndEventEmitter.emit(RowndEvent( + event: .signInCompleted, + data: [ + "method": AnyCodable(SignInType.apple.rawValue), + "user_type": AnyCodable(tokenResponse?.userType?.rawValue), + "app_variant_user_type": AnyCodable(tokenResponse?.appVariantUserType?.rawValue) + ] + )) + } + .store(in: &self.cancellables) - Context.currentContext.store.dispatch(SetLastSignInMethod(payload: SignInMethodTypes.apple)) - - let subscriber = Context.currentContext.store.subscribe { $0.auth.isAccessTokenValid } - subscriber.$current.sink { isAccessTokenValid in - if isAccessTokenValid { - subscriber.unsubscribe() - self.updateUserDataWithAppleData(fullName: fullName, email: email) - - RowndEventEmitter.emit(RowndEvent( - event: .signInCompleted, - data: [ - "method": AnyCodable(SignInType.apple.rawValue), - "user_type": AnyCodable(tokenResponse?.userType?.rawValue), - "app_variant_user_type": AnyCodable(tokenResponse?.appVariantUserType?.rawValue) - ] - )) - } - }.store(in: &self.cancellables) - } } catch ApiError.generic(let errorInfo) { if errorInfo.code == "E_SIGN_IN_USER_NOT_FOUND" { Task { @MainActor in @@ -212,50 +212,47 @@ class AppleSignUpCoordinator: NSObject, ASAuthorizationControllerDelegate, ASAut } func updateUserDataWithAppleData(fullName: PersonNameComponents?, email: String?) { - Context.currentContext.store.dispatch(Thunk { dispatch, getState in - guard let state = getState() else { return } - Task { - do { - if let userStateResponse = try await UserData.fetchUserData(state) { - var userData = state.user.data - userData.merge(userStateResponse.data) { (current, _) in current } - - let defaults = UserDefaults.standard - // use UserDefault values for Email and fullName if available - if let userAppleSignInData = defaults.object(forKey: appleSignInDataKey) as? Data { - let decoder = JSONDecoder() - if let loadedAppleSignInData = try? decoder.decode(AppleSignInData.self, from: userAppleSignInData) { - userData["email"] = AnyCodable(loadedAppleSignInData.email) - userData["first_name"] = AnyCodable(loadedAppleSignInData.firstName) - userData["last_name"] = AnyCodable(loadedAppleSignInData.lastName) - userData["full_name"] = AnyCodable(loadedAppleSignInData.fullName) - } - - // Remove the data since we no longer need it for subsequent signins. - defaults.removeObject(forKey: appleSignInDataKey) - } else { - if let email = email { - userData["email"] = AnyCodable(email) - userData["first_name"] = AnyCodable(fullName?.givenName) - userData["last_name"] = AnyCodable(fullName?.familyName) - userData["full_name"] = AnyCodable(String("\(fullName?.givenName) \(fullName?.familyName)")) - } - } + Task { + do { + if let userStateResponse = try await UserData.fetchUserData() { + var userData = Context.currentContext.store.state.user.data + userData.merge(userStateResponse.data) { (current, _) in current } - if !userData.isEmpty { - dispatch(UserData.save(userData)) - logger.debug("UserData to save after signin: \(String(describing: userData))") + let defaults = UserDefaults.standard + // use UserDefault values for Email and fullName if available + if let userAppleSignInData = defaults.object(forKey: appleSignInDataKey) as? Data { + let decoder = JSONDecoder() + if let loadedAppleSignInData = try? decoder.decode(AppleSignInData.self, from: userAppleSignInData) { + userData["email"] = AnyCodable(loadedAppleSignInData.email) + userData["first_name"] = AnyCodable(loadedAppleSignInData.firstName) + userData["last_name"] = AnyCodable(loadedAppleSignInData.lastName) + userData["full_name"] = AnyCodable(loadedAppleSignInData.fullName) } + + // Remove the data since we no longer need it for subsequent signins. + defaults.removeObject(forKey: appleSignInDataKey) } else { - // Handle the case where userStateResponse is nil - logger.error("Failed to fetch user state response") + if let email = email { + userData["email"] = AnyCodable(email) + userData["first_name"] = AnyCodable(fullName?.givenName) + userData["last_name"] = AnyCodable(fullName?.familyName) + userData["full_name"] = AnyCodable(String("\(fullName?.givenName ?? "") \(fullName?.familyName ?? "")")) + } + } + + if !userData.isEmpty { + await UserData.save(userData) + logger.debug("UserData to save after signin: \(String(describing: userData))") } - } catch { - // Handle any errors that occurred during fetch - logger.error("Error fetching user data: \(error)") + } else { + // Handle the case where userStateResponse is nil + logger.error("Failed to fetch user state response") } + } catch { + // Handle any errors that occurred during fetch + logger.error("Error fetching user data: \(error)") } - }) + } } // If authorization faced any issue then this method will get triggered @@ -263,7 +260,7 @@ class AppleSignUpCoordinator: NSObject, ASAuthorizationControllerDelegate, ASAut // If there is any error will get it here logger.error("An error occurred while signing in with Apple. Error: \(String(describing: error))") - + func defaultSignInFlow() { logger.error("Falling back to default sign flow") Rownd.requestSignIn(RowndSignInOptions(intent: intent)) diff --git a/Sources/Rownd/Models/Automations/AutomationsCoordinator.swift b/Sources/Rownd/Models/Automations/AutomationsCoordinator.swift index 79100db..4c6b648 100644 --- a/Sources/Rownd/Models/Automations/AutomationsCoordinator.swift +++ b/Sources/Rownd/Models/Automations/AutomationsCoordinator.swift @@ -6,10 +6,10 @@ // import AnyCodable +import Combine import Foundation -import ReSwift -public struct AutomationStoreState { +public struct AutomationStoreState: Sendable { var user: UserState var automations: [RowndAutomation]? var auth: AuthState @@ -34,11 +34,11 @@ func computeLastRunTimestamp(automation: RowndAutomation, meta: [String: AnyCoda return nil } -public class AutomationsCoordinator: NSObject, StoreSubscriber { +public class AutomationsCoordinator: NSObject { private var state: AutomationStoreState? - public typealias StoreSubscriberStateType = AutomationStoreState let debouncer = Debouncer(delay: 0.5) // 500ms private var isStarted = false + private var cancellable: AnyCancellable? override init() { super.init() @@ -48,29 +48,34 @@ public class AutomationsCoordinator: NSObject, StoreSubscriber { public func start() { guard !isStarted else { return } isStarted = true - Context.currentContext.store.subscribe(self) { - $0.select { + + // Subscribe to state changes using Combine + cancellable = Context.currentContext.store.publisher() + .map { state in AutomationStoreState( - user: $0.user, automations: $0.appConfig.config?.automations, auth: $0.auth, - passkeys: $0.passkeys) + user: state.user, + automations: state.appConfig.config?.automations, + auth: state.auth, + passkeys: state.passkeys + ) + } + .receive(on: DispatchQueue.main) + .sink { [weak self] newState in + self?.state = newState + self?.processAutomations() } - } } @MainActor public func stop() { guard isStarted else { return } - Context.currentContext.store.unsubscribe(self) + cancellable?.cancel() + cancellable = nil isStarted = false } deinit { - DispatchQueue.main.sync { [weak self] in self?.stop() } - } - - public func newState(state: AutomationStoreState) { - self.state = state - self.processAutomations() + cancellable?.cancel() } private func processAutomations(_ state: AutomationStoreState) { @@ -129,7 +134,7 @@ public class AutomationsCoordinator: NSObject, StoreSubscriber { actionFn(args) - // Save automatino action in meta data + // Save automation action in meta data let lastRunId = computeLastRunId(automation) Task { @MainActor in let date = NetworkTimeManager.shared.currentTime ?? Date() diff --git a/Sources/Rownd/Models/Context/AppConfig.swift b/Sources/Rownd/Models/Context/AppConfig.swift index a940cce..6f3c793 100644 --- a/Sources/Rownd/Models/Context/AppConfig.swift +++ b/Sources/Rownd/Models/Context/AppConfig.swift @@ -5,14 +5,12 @@ // Created by Matt Hamann on 6/23/22. // +import AnyCodable import Foundation -import UIKit -import ReSwift -import ReSwiftThunk import Get -import AnyCodable +import UIKit -public struct AppConfigState: Hashable { +public struct AppConfigState: Hashable, Sendable { public var isLoading: Bool = false public var id: String? public var icon: String? @@ -29,7 +27,7 @@ extension AppConfigState: Codable { } } -public struct AppConfigConfig: Hashable { +public struct AppConfigConfig: Hashable, Sendable { public var automations: [RowndAutomation]? public var hub: AppHubConfigState? public var customizations: AppCustomizationsConfigState? @@ -52,7 +50,7 @@ extension AppConfigConfig: Codable { if let automation = try? nestedContainer.decode(RowndAutomation.self) { tempAutomations.append(automation) } else { - _ = try? nestedContainer.decode(AnyCodable.self) // This line skips over the bad entry + _ = try? nestedContainer.decode(AnyCodable.self) // This line skips over the bad entry } } @@ -76,7 +74,7 @@ extension AppConfigConfig: Codable { do { try nestedContainer.encode(automation) } catch { - continue // Skip the automation if encoding fails + continue // Skip the automation if encoding fails } } } @@ -93,7 +91,7 @@ extension AppConfigConfig: Codable { } } -public struct AppSchemaField: Hashable { +public struct AppSchemaField: Hashable, Sendable { public var displayName: String? public var type: String? public var required: Bool? @@ -109,15 +107,15 @@ extension AppSchemaField: Codable { } } -public struct AppSchemaFieldEncryption: Hashable, Codable { +public struct AppSchemaFieldEncryption: Hashable, Codable, Sendable { public var state: AppSchemaEncryptionState? } -public enum AppSchemaEncryptionState: String, Codable { +public enum AppSchemaEncryptionState: String, Codable, Sendable { case enabled, disabled } -public struct AppHubConfigState: Hashable { +public struct AppHubConfigState: Hashable, Sendable { public var auth: AppHubAuthConfigState? public var customizations: AppHubCustomizationsConfigState? public var customStyles: [AppHubCustomStylesConfigState]? @@ -130,7 +128,7 @@ extension AppHubConfigState: Codable { } } -public struct AppHubAuthConfigState: Hashable { +public struct AppHubAuthConfigState: Hashable, Sendable { public var signInMethods: SignInMethods? public var useExplicitSignUpFlow: Bool? } @@ -142,7 +140,7 @@ extension AppHubAuthConfigState: Codable { } } -public struct AppCustomizationsConfigState: Hashable { +public struct AppCustomizationsConfigState: Hashable, Sendable { public var primaryColor: String? } @@ -152,7 +150,7 @@ extension AppCustomizationsConfigState: Codable { } } -public struct AppHubCustomizationsConfigState: Hashable { +public struct AppHubCustomizationsConfigState: Hashable, Sendable { public var fontFamily: String? public var darkMode: String? public var primaryColor: String? @@ -168,7 +166,7 @@ extension AppHubCustomizationsConfigState: Codable { } } -public struct AppHubCustomStylesConfigState: Hashable { +public struct AppHubCustomStylesConfigState: Hashable, Sendable { public var content: String } @@ -178,7 +176,7 @@ extension AppHubCustomStylesConfigState: Codable { } } -public struct SignInMethods: Hashable { +public struct SignInMethods: Hashable, Sendable { public var google: GoogleSignInMethodConfig? public var passkeys: PasskeysSignInMethodConfig? } @@ -189,7 +187,7 @@ extension SignInMethods: Codable { } } -public struct GoogleSignInMethodConfig: Hashable { +public struct GoogleSignInMethodConfig: Hashable, Sendable { public var enabled: Bool? public var serverClientId: String? public var iosClientId: String? @@ -203,7 +201,7 @@ extension GoogleSignInMethodConfig: Codable { } } -public struct PasskeysSignInMethodConfig: Hashable { +public struct PasskeysSignInMethodConfig: Hashable, Sendable { public var enabled: Bool? public var domains: [String]? } @@ -214,31 +212,7 @@ extension PasskeysSignInMethodConfig: Codable { } } -struct SetAppConfig: Action { - var payload: AppConfigState -} - -struct SetAppLoading: Action { - var isLoading: Bool -} - -func appConfigReducer(action: Action, state: AppConfigState?) -> AppConfigState { - var state = state ?? AppConfigState() - - switch action { - case let action as SetAppConfig: - state = action.payload - state.isLoading = false - case let action as SetAppLoading: - state.isLoading = action.isLoading - default: - break - } - - return state -} - -// MARK: API / side-effecty things +// MARK: - API / side-effect actions // Easily unwrap the main payload from the `app` key struct AppConfigResponse: Decodable { @@ -246,22 +220,21 @@ struct AppConfigResponse: Decodable { } class AppConfig { - static func requestAppState() -> Thunk { - return Thunk { dispatch, getState in - guard let state = getState() else { return } - guard !state.appConfig.isLoading else { return } - dispatch(SetAppLoading(isLoading: true)) - - Task { - let appConfig = await AppConfig.fetch() - - DispatchQueue.main.async { - if let appConfig = appConfig { - dispatch(SetAppConfig(payload: appConfig.app)) - } - dispatch(SetAppLoading(isLoading: false)) - } + static func requestAppState() async { + let state = Context.currentContext.store.state + guard !state.appConfig.isLoading else { return } + + await Context.currentContext.store.mutate { state in + state.appConfig.isLoading = true + } + + let appConfig = await AppConfig.fetch() + + await Context.currentContext.store.mutate { state in + if let appConfig = appConfig { + state.appConfig = appConfig.app } + state.appConfig.isLoading = false } } diff --git a/Sources/Rownd/Models/Context/AuthState.swift b/Sources/Rownd/Models/Context/AuthState.swift index 15425bf..10493bb 100644 --- a/Sources/Rownd/Models/Context/AuthState.swift +++ b/Sources/Rownd/Models/Context/AuthState.swift @@ -5,15 +5,13 @@ // Created by Matt Hamann on 6/25/22. // +import AnyCodable import Foundation -import UIKit -import ReSwift -import ReSwiftThunk -import JWTDecode import Get -import AnyCodable +import JWTDecode +import UIKit -public struct AuthState: Hashable, CustomStringConvertible { +public struct AuthState: Hashable, CustomStringConvertible, Sendable { public var isLoading: Bool = false public var accessToken: String? public var refreshToken: String? @@ -34,7 +32,7 @@ extension AuthState: Codable { } public var isAuthenticatedWithUserData: Bool { - if (!isAuthenticated) { + if !isAuthenticated { return false } @@ -75,7 +73,7 @@ extension AuthState: Codable { let userId: String? = Context.currentContext.store.state.user.get(field: "user_id") as? String ?? nil let rphInit = RphInit(accessToken: self.accessToken, refreshToken: self.refreshToken, appId: Context.currentContext.store.state.appConfig.id, appUserId: userId) - + do { return try rphInit.valueForURLFragment() } catch { @@ -108,76 +106,27 @@ extension AuthState: Codable { } } -func onReceiveAuthTokens(_ newAuthState: AuthState) -> Thunk { - return Thunk { dispatch, getState in - guard let _ = getState() else { return } - - Task { - // This is a special case to get the new auth state over - // to the authenticator as quickly as possible without - // waiting for the store update flow to complete - - DispatchQueue.main.async { - dispatch(SetAuthState(payload: newAuthState)) - dispatch(UserData.fetch()) - dispatch(PasskeyData.fetchPasskeyRegistration()) - } - } - + /// Handle receiving new auth tokens and update state. + func onReceiveAuthTokens(_ newAuthState: AuthState) { + Task { + await Context.currentContext.store.setAuth(newAuthState) + await UserData.fetch() + await PasskeyData.fetchPasskeyRegistration() } } - func onReceiveAppleAuthTokens(_ newAuthState: AuthState) -> Thunk { - return Thunk { dispatch, getState in - guard let _ = getState() else { return } - - Task { - // This is a special case to get the new auth state over - // to the authenticator as quickly as possible without - // waiting for the store update flow to complete - - DispatchQueue.main.async { - dispatch(SetAuthState(payload: newAuthState)) - dispatch(PasskeyData.fetchPasskeyRegistration()) - } - } - + /// Handle receiving Apple auth tokens (doesn't fetch user data). + func onReceiveAppleAuthTokens(_ newAuthState: AuthState) { + Task { + await Context.currentContext.store.setAuth(newAuthState) + await PasskeyData.fetchPasskeyRegistration() } } } -// MARK: Reducers - -struct SetAuthState: Action { - var payload = AuthState() -} - -func authReducer(action: Action, state: AuthState?) -> AuthState { - var state = state ?? AuthState() - - let hasPreviouslySignedIn = state.hasPreviouslySignedIn - - switch action { - case let action as SetAuthState: - state = action.payload - case let action as SetUserData: - state.userId = action.data["user_id"]?.value as? String - case let action as SetUserState: - state.userId = action.payload.data["user_id"]?.value as? String - default: - break - } - - if hasPreviouslySignedIn ?? false || state.isAuthenticated { - state.hasPreviouslySignedIn = true - } - - return state -} - // MARK: Token / auth API calls -public enum UserType: String, Codable { +public enum UserType: String, Codable, Sendable { case NewUser = "new_user" case ExistingUser = "existing_user" case Unknown = "unknown" @@ -275,17 +224,17 @@ class Auth { return tokenResp.value } - + static func signOutUser(signOutRequest: SignOutRequest) async throws { - guard let appId = Context.currentContext.store.state.appConfig.id else { throw RowndError("AppId not found") } - + guard let appId = Context.currentContext.store.state.appConfig.id else { throw RowndError("AppId not found") } + try await Rownd.apiClient.send(Request( path: "/me/applications/\(appId)/signout", method: .post, body: signOutRequest )) - + } } diff --git a/Sources/Rownd/Models/Context/Context.swift b/Sources/Rownd/Models/Context/Context.swift index 2ff08a7..79583ae 100644 --- a/Sources/Rownd/Models/Context/Context.swift +++ b/Sources/Rownd/Models/Context/Context.swift @@ -6,23 +6,30 @@ // import Foundation -import ReSwift class Context { public internal(set) static var currentContext: Context = Context() - let store: Store + /// The state store - replaces the old ReSwift Store. + let store: StateStore var eventListeners: [RowndEventHandlerDelegate] = [] var authenticator: AuthenticatorProtocol = Authenticator() init() { - store = createStore() + store = StateStore() } - init(_ store: Store) { + init(_ store: StateStore) { self.store = store Context.currentContext = self } } + +// MARK: - Test Helpers + +/// Creates a new StateStore for testing purposes. +func createStore() -> StateStore { + return StateStore() +} diff --git a/Sources/Rownd/Models/Context/ObservableRowndState.swift b/Sources/Rownd/Models/Context/ObservableRowndState.swift new file mode 100644 index 0000000..434830f --- /dev/null +++ b/Sources/Rownd/Models/Context/ObservableRowndState.swift @@ -0,0 +1,348 @@ +// +// ObservableRowndState.swift +// Rownd +// +// iOS 17+ @Observable wrapper for automatic SwiftUI integration. +// For iOS 14-16, use the legacy ObservableState classes. +// + +import Combine +import Foundation +import SwiftUI + +// MARK: - iOS 17+ Observable State + +#if swift(>=5.9) +@available(iOS 17.0, macOS 14.0, *) +@Observable +public final class ObservableRowndState { + // MARK: - State Properties + + public private(set) var auth: AuthState = AuthState() + public private(set) var user: UserState = UserState() + public private(set) var appConfig: AppConfigState = AppConfigState() + public private(set) var passkeys: PasskeyState = PasskeyState() + public private(set) var signIn: SignInState = SignInState() + public private(set) var isStateLoaded: Bool = false + internal private(set) var clockSyncState: ClockSyncState = .unknown + + // MARK: - Computed Properties + + public var isAuthenticated: Bool { + auth.accessToken != nil + } + + public var isInitialized: Bool { + isStateLoaded && clockSyncState != .waiting + } + + // MARK: - Private + + private var cancellables = Set() + private var observationTask: Task? + + // MARK: - Initialization + + public init() { + // Get initial state + let currentState = Context.currentContext.store.state + updateFromState(currentState) + + // Subscribe to state changes + startObserving() + } + + deinit { + observationTask?.cancel() + cancellables.removeAll() + } + + // MARK: - Observation + + private func startObserving() { + // Use Combine publisher for reactive updates + Context.currentContext.store.publisher() + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.updateFromState(state) + } + .store(in: &cancellables) + } + + private func updateFromState(_ state: RowndState) { + if auth != state.auth { + auth = state.auth + } + if user != state.user { + user = state.user + } + if appConfig != state.appConfig { + appConfig = state.appConfig + } + if passkeys != state.passkeys { + passkeys = state.passkeys + } + if signIn != state.signIn { + signIn = state.signIn + } + if isStateLoaded != state.isStateLoaded { + isStateLoaded = state.isStateLoaded + } + if clockSyncState != state.clockSyncState { + clockSyncState = state.clockSyncState + } + } +} +#endif + +// MARK: - Legacy Observable State (iOS 14+) + +/// Observable state wrapper for SwiftUI that uses Combine internally. +/// This is the backward-compatible replacement for the ReSwift-based ObservableState. +public final class LegacyObservableState: ObservableObject { + @Published public private(set) var current: T + + private let selector: (RowndState) -> T + private let animation: SwiftUI.Animation? + private var cancellable: AnyCancellable? + private let store: StateStore + + public let objectDidChange = PassthroughSubject, Never>() + + public init( + store: StateStore, + selector: @escaping (RowndState) -> T, + animation: SwiftUI.Animation? = nil + ) { + self.store = store + self.selector = selector + self.animation = animation + self.current = selector(store.state) + + subscribe() + } + + private func subscribe() { + cancellable = store.publisher() + .map { [selector] state in selector(state) } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] newValue in + guard let self = self else { return } + let oldValue = self.current + guard oldValue != newValue else { return } + + if let animation = self.animation { + withAnimation(animation) { + self.current = newValue + } + } else { + self.current = newValue + } + + self.objectDidChange.send(StateChange(old: oldValue, new: newValue)) + } + } + + public func unsubscribe() { + cancellable?.cancel() + cancellable = nil + } + + deinit { + unsubscribe() + } +} + +// MARK: - Legacy Throttled Observable State + +/// Throttled observable state wrapper for SwiftUI. +public final class LegacyObservableThrottledState: ObservableObject { + @Published public private(set) var current: T + + private let selector: (RowndState) -> T + private let animation: SwiftUI.Animation? + private var cancellable: AnyCancellable? + private let store: StateStore + private let throttleMs: Int + + public let objectDidChange = PassthroughSubject, Never>() + + public init( + store: StateStore, + selector: @escaping (RowndState) -> T, + animation: SwiftUI.Animation? = nil, + throttleInMs: Int = 350 + ) { + self.store = store + self.selector = selector + self.animation = animation + self.throttleMs = throttleInMs + self.current = selector(store.state) + + subscribe() + } + + private func subscribe() { + cancellable = store.publisher() + .map { [selector] state in selector(state) } + .removeDuplicates() + .throttle(for: .milliseconds(throttleMs), scheduler: DispatchQueue.main, latest: true) + .receive(on: DispatchQueue.main) + .sink { [weak self] newValue in + guard let self = self else { return } + let oldValue = self.current + guard oldValue != newValue else { return } + + if let animation = self.animation { + withAnimation(animation) { + self.current = newValue + } + } else { + self.current = newValue + } + + self.objectDidChange.send(StateChange(old: oldValue, new: newValue)) + } + } + + public func unsubscribe() { + cancellable?.cancel() + cancellable = nil + } + + deinit { + unsubscribe() + } +} + +// MARK: - Legacy Derived Observable State + +/// Observable state that derives a new value from the source state. +public final class LegacyObservableDerivedState: ObservableObject { + @Published public private(set) var current: Derived + + private let selector: (RowndState) -> Original + private let transform: (Original) -> Derived + private let animation: SwiftUI.Animation? + private var cancellable: AnyCancellable? + private let store: StateStore + + public let objectWillChange = PassthroughSubject, Never>() + public let objectDidChange = PassthroughSubject, Never>() + + public init( + store: StateStore, + selector: @escaping (RowndState) -> Original, + transform: @escaping (Original) -> Derived, + animation: SwiftUI.Animation? = nil + ) { + self.store = store + self.selector = selector + self.transform = transform + self.animation = animation + self.current = transform(selector(store.state)) + + subscribe() + } + + private func subscribe() { + cancellable = store.publisher() + .map { [selector] state in selector(state) } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] originalValue in + guard let self = self else { return } + let newValue = self.transform(originalValue) + let oldValue = self.current + guard oldValue != newValue else { return } + + self.objectWillChange.send(StateChange(old: oldValue, new: newValue)) + + if let animation = self.animation { + withAnimation(animation) { + self.current = newValue + } + } else { + self.current = newValue + } + + self.objectDidChange.send(StateChange(old: oldValue, new: newValue)) + } + } + + public func unsubscribe() { + cancellable?.cancel() + cancellable = nil + } + + deinit { + unsubscribe() + } +} + +// MARK: - Legacy Derived Throttled Observable State + +/// Throttled observable state that derives a new value from the source state. +public final class LegacyObservableDerivedThrottledState: ObservableObject { + @Published public private(set) var current: Derived + + private let selector: (RowndState) -> Original + private let transform: (Original) -> Derived + private let animation: SwiftUI.Animation? + private var cancellable: AnyCancellable? + private let store: StateStore + private let throttleMs: Int + + public let objectDidChange = PassthroughSubject, Never>() + + public init( + store: StateStore, + selector: @escaping (RowndState) -> Original, + transform: @escaping (Original) -> Derived, + animation: SwiftUI.Animation? = nil, + throttleInMs: Int = 350 + ) { + self.store = store + self.selector = selector + self.transform = transform + self.animation = animation + self.throttleMs = throttleInMs + self.current = transform(selector(store.state)) + + subscribe() + } + + private func subscribe() { + cancellable = store.publisher() + .map { [selector] state in selector(state) } + .removeDuplicates() + .throttle(for: .milliseconds(throttleMs), scheduler: DispatchQueue.main, latest: true) + .receive(on: DispatchQueue.main) + .sink { [weak self] originalValue in + guard let self = self else { return } + let newValue = self.transform(originalValue) + let oldValue = self.current + guard oldValue != newValue else { return } + + if let animation = self.animation { + withAnimation(animation) { + self.current = newValue + } + } else { + self.current = newValue + } + + self.objectDidChange.send(StateChange(old: oldValue, new: newValue)) + } + } + + public func unsubscribe() { + cancellable?.cancel() + cancellable = nil + } + + deinit { + unsubscribe() + } +} diff --git a/Sources/Rownd/Models/Context/Passkeys.swift b/Sources/Rownd/Models/Context/Passkeys.swift index 09b65de..e0b056f 100644 --- a/Sources/Rownd/Models/Context/Passkeys.swift +++ b/Sources/Rownd/Models/Context/Passkeys.swift @@ -1,18 +1,16 @@ // -// Auth.swift +// Passkeys.swift // framework // // Created by Matt Hamann on 7/8/22. // -import Foundation -import UIKit -import ReSwift -import ReSwiftThunk import AnyCodable +import Foundation import Get +import UIKit -public struct PasskeyRegistration: Hashable { +public struct PasskeyRegistration: Hashable, Sendable { public var id: String? } @@ -22,7 +20,7 @@ extension PasskeyRegistration: Codable { } } -public struct PasskeyState: Hashable { +public struct PasskeyState: Hashable, Sendable { public var isLoading: Bool = false public var isInitialized: Bool = false public var isErrored: Bool = false @@ -40,52 +38,7 @@ extension PasskeyState: Codable { } } -struct SetPasskeyState: Action { - public var payload = PasskeyState() -} - -struct SetPasskeyLoading: Action { - var isLoading: Bool -} - -struct SetPasskeyInitialized: Action { - var isInitialized: Bool -} - -struct SetPasskeyRegistration: Action { - var payload: [PasskeyRegistration] -} - -struct SetPasskeyError: Action { - var isErrored: Bool = true - var errorMessage: String -} - -func passkeyReducer(action: Action, state: PasskeyState?) -> PasskeyState { - var state = state ?? PasskeyState() - - switch action { - case let action as SetPasskeyState: - state = action.payload - case let action as SetPasskeyLoading: - state.isLoading = action.isLoading - case let action as SetPasskeyInitialized: - state.isInitialized = action.isInitialized - case let action as SetPasskeyRegistration: - state.isInitialized = true - state.registration = action.payload - case let action as SetAuthState: - if !action.payload.isAuthenticated { - state = PasskeyState() - } - default: - break - } - - return state -} - -public struct PasskeysRegistrationResponse: Hashable { +public struct PasskeysRegistrationResponse: Hashable, Sendable { public var passkeys: [PasskeyRegistration] } @@ -96,44 +49,43 @@ extension PasskeysRegistrationResponse: Codable { } class PasskeyData { - static func fetchPasskeyRegistration() -> Thunk { - return Thunk { dispatch, getState in - guard let state = getState() else { return } - guard !state.passkeys.isLoading else { return } - - if Context.currentContext.store.state.appConfig.config?.hub?.auth?.signInMethods?.passkeys?.enabled != true { - logger.debug("Passkeys are not enabled") - return - } + static func fetchPasskeyRegistration() async { + let state = Context.currentContext.store.state + guard !state.passkeys.isLoading else { return } - Task { - guard state.auth.isAuthenticated else { - return - } + if Context.currentContext.store.state.appConfig.config?.hub?.auth?.signInMethods?.passkeys?.enabled != true { + logger.debug("Passkeys are not enabled") + return + } - DispatchQueue.main.async { - dispatch(SetPasskeyLoading(isLoading: true)) - } + guard state.auth.isAuthenticated else { + return + } - defer { - DispatchQueue.main.async { - dispatch(SetPasskeyLoading(isLoading: false)) - } - } + await Context.currentContext.store.mutate { state in + state.passkeys.isLoading = true + } - do { - let response = try await Rownd.apiClient.send(Request(path: "/me/auth/passkeys", method: .get)).value + defer { + Task { + await Context.currentContext.store.mutate { state in + state.passkeys.isLoading = false + } + } + } - logger.debug("Passkey response: \(String(describing: response))") + do { + let response = try await Rownd.apiClient.send(Request(path: "/me/auth/passkeys", method: .get)).value - DispatchQueue.main.async { - dispatch(SetPasskeyRegistration(payload: response.passkeys)) - } + logger.debug("Passkey response: \(String(describing: response))") - } catch { - logger.error("Failed to retrieve passkeys: \(String(describing: error))") - } + await Context.currentContext.store.mutate { state in + state.passkeys.isInitialized = true + state.passkeys.registration = response.passkeys } + + } catch { + logger.error("Failed to retrieve passkeys: \(String(describing: error))") } } } diff --git a/Sources/Rownd/Models/Context/ReSwiftObserver.swift b/Sources/Rownd/Models/Context/ReSwiftObserver.swift index ae86824..f530b4a 100644 --- a/Sources/Rownd/Models/Context/ReSwiftObserver.swift +++ b/Sources/Rownd/Models/Context/ReSwiftObserver.swift @@ -4,272 +4,304 @@ // // Created by Matt Hamann on 6/27/22. // +// This file provides backward-compatible observable state classes that wrap +// the new StateStore. The public API remains the same for existing consumers. +// import Combine import Foundation -import ReSwift import SwiftUI -// MARK: - Main Thread Dispatch Helper - -/// Helper to centralize main-thread dispatch with weak self handling. -/// Reduces duplication and ensures consistent patterns across observable state types. -private func dispatchOnMain(_ instance: T, execute work: @escaping (T) -> Void) { - DispatchQueue.main.async { [weak instance] in - guard let instance = instance else { return } - work(instance) - } -} - -public class ObservableState: ObservableObject, StoreSubscriber, ObservableSubscription -{ +// MARK: - Backward Compatible Observable State +/// Observable state wrapper for SwiftUI - backward compatible with the old ReSwift-based API. +/// Use this with @StateObject in SwiftUI views. +public class ObservableState: ObservableObject, ObservableSubscription { @Published fileprivate(set) public var current: T + let selector: (RowndState) -> T fileprivate let animation: SwiftUI.Animation? + fileprivate var cancellable: AnyCancellable? fileprivate var isSubscribed: Bool = false - fileprivate var cancellables = Set() + + public let objectDidChange = PassthroughSubject, Never>() + + public struct DidChangeSubject { + public let old: S + public let new: S + } // MARK: Lifecycle - public init(select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil) - { - self.current = selector(Context.currentContext.store.state) + public init(select selector: @escaping (RowndState) -> T, animation: SwiftUI.Animation? = nil) { self.selector = selector self.animation = animation + self.current = selector(Context.currentContext.store.state) self.subscribe() } public func subscribe() { guard !isSubscribed else { return } - // Capture selector directly to avoid retaining self in the transform closure - let selector = self.selector - dispatchOnMain(self) { instance in - guard !instance.isSubscribed else { return } - Context.currentContext.store.subscribe( - instance, transform: { $0.select(selector) }) - instance.isSubscribed = true - } + isSubscribed = true + + cancellable = Context.currentContext.store.publisher() + .map { [selector] state in selector(state) } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] newValue in + self?.handleNewState(newValue) + } } func unsubscribe() { guard isSubscribed else { return } - dispatchOnMain(self) { instance in - guard instance.isSubscribed else { return } - Context.currentContext.store.unsubscribe(instance) - instance.isSubscribed = false - } + cancellable?.cancel() + cancellable = nil + isSubscribed = false } deinit { unsubscribe() } - public func newState(state: T) { - // All @Published property access must happen on main thread - dispatchOnMain(self) { instance in - guard instance.current != state else { return } - let old = instance.current - if let animation = instance.animation { - withAnimation(animation) { - instance.current = state - } - } else { - instance.current = state + fileprivate func handleNewState(_ state: T) { + guard current != state else { return } + let old = current + + if let animation = animation { + withAnimation(animation) { + current = state } - instance.objectDidChange.send(DidChangeSubject(old: old, new: instance.current)) + } else { + current = state } - } - public let objectDidChange = PassthroughSubject, Never>() + objectDidChange.send(DidChangeSubject(old: old, new: current)) + } - public struct DidChangeSubject { - let old: S - let new: S + /// Legacy method for ReSwift compatibility - now receives state from Combine publisher. + public func newState(state: T) { + DispatchQueue.main.async { [weak self] in + self?.handleNewState(state) + } } } -public class ObservableThrottledState: ObservableState { +// MARK: - Throttled Observable State - // MARK: Lifecycle +public class ObservableThrottledState: ObservableState { + private let throttleMs: Int + private let throttledSubject = PassthroughSubject() + private var throttleCancellable: AnyCancellable? public init( - select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil, + select selector: @escaping (RowndState) -> T, + animation: SwiftUI.Animation? = nil, throttleInMs: Int ) { + self.throttleMs = throttleInMs super.init(select: selector, animation: animation) - objectThrottled - .throttle(for: .milliseconds(throttleInMs), scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] in self?.current = $0 } - .store(in: &cancellables) - } - - override public func newState(state: T) { - // All @Published property access must happen on main thread to avoid crashes - // in swift_retain when accessing Combine's Published wrapper from background threads - dispatchOnMain(self) { instance in - guard instance.current != state else { return } - let old = instance.current - if let animation = instance.animation { - withAnimation(animation) { - instance.objectThrottled.send(state) + throttleCancellable = throttledSubject + .throttle(for: .milliseconds(throttleMs), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] value in + guard let self = self else { return } + if let animation = self.animation { + withAnimation(animation) { + self.current = value + } + } else { + self.current = value } - } else { - instance.objectThrottled.send(state) } - instance.objectDidChange.send(DidChangeSubject(old: old, new: instance.current)) - } } - private let objectThrottled = PassthroughSubject() + override func handleNewState(_ state: T) { + guard current != state else { return } + let old = current + throttledSubject.send(state) + objectDidChange.send(DidChangeSubject(old: old, new: current)) + } + + override func unsubscribe() { + throttleCancellable?.cancel() + throttleCancellable = nil + super.unsubscribe() + } } -public class ObservableDerivedState: ObservableObject, - StoreSubscriber, ObservableSubscription -{ +// MARK: - Derived Observable State + +public class ObservableDerivedState: ObservableObject, ObservableSubscription { @Published public var current: Derived let selector: (RowndState) -> Original let transform: (Original) -> Derived fileprivate let animation: SwiftUI.Animation? + fileprivate var cancellable: AnyCancellable? fileprivate var isSubscribed: Bool = false - fileprivate var cancellables = Set() - // MARK: Lifecycle + public let objectWillChange = PassthroughSubject, Never>() + public let objectDidChange = PassthroughSubject, Never>() + + public struct ChangeSubject { + public let old: DerivedSub + public let new: DerivedSub + } public init( select selector: @escaping (RowndState) -> Original, - transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil + transform: @escaping (Original) -> Derived, + animation: SwiftUI.Animation? = nil ) { - self.current = transform(selector(Context.currentContext.store.state)) self.selector = selector self.transform = transform self.animation = animation + self.current = transform(selector(Context.currentContext.store.state)) self.subscribe() } func subscribe() { guard !isSubscribed else { return } - // Capture selector directly to avoid retaining self in the transform closure - let selector = self.selector - dispatchOnMain(self) { instance in - guard !instance.isSubscribed else { return } - Context.currentContext.store.subscribe( - instance, transform: { $0.select(selector) }) - instance.isSubscribed = true - } + isSubscribed = true + + cancellable = Context.currentContext.store.publisher() + .map { [selector] state in selector(state) } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] originalValue in + self?.handleNewState(originalValue) + } } func unsubscribe() { guard isSubscribed else { return } - dispatchOnMain(self) { instance in - guard instance.isSubscribed else { return } - Context.currentContext.store.unsubscribe(instance) - instance.isSubscribed = false - } + cancellable?.cancel() + cancellable = nil + isSubscribed = false } deinit { unsubscribe() } - public func newState(state original: Original) { - dispatchOnMain(self) { instance in - let old = instance.current - instance.objectWillChange.send(ChangeSubject(old: old, new: instance.current)) + fileprivate func handleNewState(_ original: Original) { + let newValue = transform(original) + guard current != newValue else { return } + let old = current - if let animation = instance.animation { - withAnimation(animation) { - instance.current = instance.transform(original) - } - } else { - instance.current = instance.transform(original) + objectWillChange.send(ChangeSubject(old: old, new: newValue)) + + if let animation = animation { + withAnimation(animation) { + current = newValue } - instance.objectDidChange.send(ChangeSubject(old: old, new: instance.current)) + } else { + current = newValue } - } - public let objectWillChange = PassthroughSubject, Never>() - public let objectDidChange = PassthroughSubject, Never>() + objectDidChange.send(ChangeSubject(old: old, new: current)) + } - public struct ChangeSubject { - let old: DerivedSub - let new: DerivedSub + /// Legacy method for ReSwift compatibility. + public func newState(state original: Original) { + DispatchQueue.main.async { [weak self] in + self?.handleNewState(original) + } } } -public class ObservableDerivedThrottledState: - ObservableDerivedState -{ +// MARK: - Derived Throttled Observable State - // MARK: Lifecycle +public class ObservableDerivedThrottledState: ObservableDerivedState { + private let throttleMs: Int + private let throttledSubject = PassthroughSubject() + private var throttleCancellable: AnyCancellable? public init( select selector: @escaping (RowndState) -> Original, - transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil, + transform: @escaping (Original) -> Derived, + animation: SwiftUI.Animation? = nil, throttleInMs: Int ) { + self.throttleMs = throttleInMs super.init(select: selector, transform: transform, animation: animation) - objectThrottled - .throttle(for: .milliseconds(throttleInMs), scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] in - self?.current = transform($0) + throttleCancellable = throttledSubject + .throttle(for: .milliseconds(throttleMs), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] value in + guard let self = self else { return } + let newValue = self.transform(value) + if let animation = self.animation { + withAnimation(animation) { + self.current = newValue + } + } else { + self.current = newValue + } } - .store(in: &cancellables) } - override public func newState(state original: Original) { - dispatchOnMain(self) { instance in - let old = instance.current - if let animation = instance.animation { - withAnimation(animation) { - instance.objectThrottled.send(original) - } - } else { - instance.objectThrottled.send(original) - } - instance.objectDidChange.send(ChangeSubject(old: old, new: instance.current)) - } + override func handleNewState(_ original: Original) { + let old = current + throttledSubject.send(original) + objectDidChange.send(ChangeSubject(old: old, new: current)) } - private let objectThrottled = PassthroughSubject() + override func unsubscribe() { + throttleCancellable?.cancel() + throttleCancellable = nil + super.unsubscribe() + } } -extension Store where State == RowndState { +// MARK: - StateStore Extensions for Backward Compatibility - public func subscribe( - select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil +extension StateStore { + /// Subscribe to a state slice - backward compatible with old ReSwift Store API. + public func subscribe( + select selector: @escaping (RowndState) -> T, + animation: SwiftUI.Animation? = nil ) -> ObservableState { ObservableState(select: selector, animation: animation) } - public func subscribe( - select selector: @escaping (RowndState) -> (Original), - transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil + /// Subscribe to a derived state - backward compatible with old ReSwift Store API. + public func subscribe( + select selector: @escaping (RowndState) -> Original, + transform: @escaping (Original) -> Derived, + animation: SwiftUI.Animation? = nil ) -> ObservableDerivedState { ObservableDerivedState(select: selector, transform: transform, animation: animation) } - public func subscribeThrottled( - select selector: @escaping (RowndState) -> (T), throttleInMs: Int = 350, + /// Subscribe to a throttled state slice - backward compatible with old ReSwift Store API. + public func subscribeThrottled( + select selector: @escaping (RowndState) -> T, + throttleInMs: Int = 350, animation: SwiftUI.Animation? = nil ) -> ObservableThrottledState { ObservableThrottledState(select: selector, animation: animation, throttleInMs: throttleInMs) } - public func subscribeThrottled( - select selector: @escaping (RowndState) -> (Original), - transform: @escaping (Original) -> Derived, throttleInMs: Int = 350, + /// Subscribe to a throttled derived state - backward compatible with old ReSwift Store API. + public func subscribeThrottled( + select selector: @escaping (RowndState) -> Original, + transform: @escaping (Original) -> Derived, + throttleInMs: Int = 350, animation: SwiftUI.Animation? = nil ) -> ObservableDerivedThrottledState { ObservableDerivedThrottledState( - select: selector, transform: transform, animation: animation, throttleInMs: throttleInMs + select: selector, + transform: transform, + animation: animation, + throttleInMs: throttleInMs ) } } +// MARK: - Protocol + protocol ObservableSubscription { func unsubscribe() } diff --git a/Sources/Rownd/Models/Context/RowndState.swift b/Sources/Rownd/Models/Context/RowndState.swift index 03bf1f1..92a48e7 100644 --- a/Sources/Rownd/Models/Context/RowndState.swift +++ b/Sources/Rownd/Models/Context/RowndState.swift @@ -7,16 +7,12 @@ import Foundation import OSLog -import ReSwift -import ReSwiftThunk private let log = Logger(subsystem: "io.rownd.sdk", category: "state") private let STORAGE_STATE_KEY = "RowndState" -private let debouncer = Debouncer(delay: 0.1) // 100ms - -public struct RowndState: Codable, Hashable { +public struct RowndState: Codable, Hashable, Sendable { public var isStateLoaded = false internal var clockSyncState: ClockSyncState = NetworkTimeManager.shared.currentTime != nil ? .synced : .waiting public var appConfig = AppConfigState() @@ -25,6 +21,18 @@ public struct RowndState: Codable, Hashable { public var passkeys = PasskeyState() public var signIn = SignInState() public var lastUpdateTs = Date() + + /// Creates a new RowndState with default values. + public init() { + self.isStateLoaded = false + self.clockSyncState = NetworkTimeManager.shared.currentTime != nil ? .synced : .waiting + self.appConfig = AppConfigState() + self.auth = AuthState() + self.user = UserState() + self.passkeys = PasskeyState() + self.signIn = SignInState() + self.lastUpdateTs = Date() + } } extension RowndState { @@ -46,94 +54,6 @@ extension RowndState { lastUpdateTs = try container.decodeIfPresent(Date.self, forKey: .lastUpdateTs) ?? Date() } - internal func save() { - debouncer.debounce(action: { - if let encoded = try? self.toJson() { - Storage.shared.set(encoded, forKey: STORAGE_STATE_KEY) - DarwinNotificationManager.shared.postNotification(name: "io.rownd.events.StateUpdated") - log.trace("Wrote state to storage \(Redact.redactSensitiveKeys(in: encoded).data(using: .utf8)?.prettyPrintedJSONString)") - } - }) - } - - @discardableResult - public func load() async -> RowndState { - return await load(Context.currentContext.store) - } - - @discardableResult - internal func load(_ store: Store) async -> RowndState { - let existingStateStr = Storage.shared.get(forKey: STORAGE_STATE_KEY) -// log.trace("initial store state: \(String(describing: existingStateStr))") - - DarwinNotificationManager.shared.startObserving(name: "io.rownd.events.StateUpdated") { - debouncer.debounce { - Task { - await self.reload() - } - } - } - - guard let existingStateStr = existingStateStr else { - await MainActor.run { - store.dispatch(SetStateLoaded()) - } - return store.state - } - - do { - let decoder = JSONDecoder() - var decoded = try decoder.decode( - RowndState.self, - from: (existingStateStr.data(using: .utf8) ?? Data()) - ) - decoded.isStateLoaded = true - decoded.clockSyncState = NetworkTimeManager.shared.currentTime != nil ? .synced : store.state.clockSyncState - await MainActor.run { [decoded] in - store.dispatch(InitializeRowndState(payload: decoded)) - } - - return decoded - } catch { - log.debug("Failed decoding state from storage (if this is the first time launching the app, this is expected): \(String(describing: error))") - } - - return store.state - } - - internal func reload() async { - await reload(Context.currentContext.store) - } - - internal func reload(_ store: Store) async { - let existingStateStr = Storage.shared.get(forKey: STORAGE_STATE_KEY) - log.trace("Retrieved store state: \(Redact.redactSensitiveKeys(in: existingStateStr ?? ""), privacy: .private)") - - guard let existingStateStr = existingStateStr else { - return - } - - do { - let decoder = JSONDecoder() - let decoded = try decoder.decode( - RowndState.self, - from: (existingStateStr.data(using: .utf8) ?? Data()) - ) - - log.trace("Retrieved auth state: \(String(describing: decoded.auth), privacy: .private)") - - if decoded.lastUpdateTs.timeIntervalSinceReferenceDate == store.state.lastUpdateTs.timeIntervalSinceReferenceDate { - return - } - - await MainActor.run { [decoded] in - store.dispatch(ReloadRowndState(payload: decoded)) - } - } catch { - log.debug("Failed decoding state from storage (if this is the first time launching the app, this is expected): \(String(describing: error))") - } - } - public func toJson() throws -> String? { let encoder = JSONEncoder() if let encoded = try? encoder.encode(self) { @@ -150,77 +70,7 @@ extension RowndState { } } -struct SetStateLoaded: Action {} - -struct InitializeRowndState: Action { - var payload: RowndState -} - -struct SetClockSync: Action { - var clockSyncState: ClockSyncState -} - -struct ReloadRowndState: Action { - var payload: RowndState -} - -func rowndStateReducer(action: Action, state: RowndState?) -> RowndState { - var newState: RowndState - switch action { - case _ as SetStateLoaded: - newState = state ?? Context.currentContext.store.state - newState.isStateLoaded = true - case let initializeAction as InitializeRowndState: - newState = initializeAction.payload - newState.clockSyncState = state?.clockSyncState ?? Context.currentContext.store.state.clockSyncState - case let clockSyncAction as SetClockSync: - newState = state ?? Context.currentContext.store.state - newState.clockSyncState = clockSyncAction.clockSyncState - case let reloadAction as ReloadRowndState: - newState = reloadAction.payload - newState.clockSyncState = state?.clockSyncState ?? .unknown - newState.isStateLoaded = state?.isStateLoaded ?? true - default: - newState = RowndState( - isStateLoaded: true, - clockSyncState: state?.clockSyncState ?? Context.currentContext.store.state.clockSyncState, - appConfig: appConfigReducer(action: action, state: state?.appConfig), - auth: authReducer(action: action, state: state?.auth), - user: userReducer(action: action, state: state?.user), - passkeys: passkeyReducer(action: action, state: state?.passkeys), - signIn: signInReducer(action: action, state: state?.signIn) - ) - - newState.save() - } - - log.trace("Internal state update \(String(describing: action), privacy: .auto)") - - if !newState.auth.isAuthenticated && (state?.auth.isAuthenticated == true) { - if #available(iOS 15.0, *) { - Task { - // TODO: enable w/ telemetry - // fetchRecentLogs(secondsBack: 10) - } - } - } - - return newState -} - -func createStore() -> Store { - return Store( - reducer: rowndStateReducer, - state: RowndState(), - middleware: [ - thunkMiddleware, - authenticatorMiddleware - ] - ) -} - -let thunkMiddleware: Middleware = createThunkMiddleware() -let authenticatorMiddleware: Middleware = AuthenticatorSubscription.createAuthenticatorMiddleware() +// MARK: - State Errors struct StateError: Error, CustomStringConvertible { var message: String diff --git a/Sources/Rownd/Models/Context/SignIn.swift b/Sources/Rownd/Models/Context/SignIn.swift index 79acca9..20e121b 100644 --- a/Sources/Rownd/Models/Context/SignIn.swift +++ b/Sources/Rownd/Models/Context/SignIn.swift @@ -1,5 +1,5 @@ // -// Auth.swift +// SignIn.swift // framework // // Created by Matt Hamann on 6/25/22. @@ -7,8 +7,6 @@ import Foundation import UIKit -import ReSwift -import ReSwiftThunk extension Date { static func ISOStringFromDate(date: Date) -> String { @@ -21,11 +19,11 @@ extension Date { } } -public enum SignInMethodTypes: String, Codable { +public enum SignInMethodTypes: String, Codable, Sendable { case apple, google } -public struct SignInState: Hashable, Codable { +public struct SignInState: Hashable, Codable, Sendable { public var lastSignIn: SignInMethodTypes? public var lastSignInDate: String? @@ -51,25 +49,14 @@ public struct SignInState: Hashable, Codable { } } -// MARK: Reducers -struct SetLastSignInMethod: Action { - var payload: SignInMethodTypes -} - -struct ResetSignInState: Action {} - -func signInReducer(action: Action, state: SignInState?) -> SignInState { - var state = state ?? SignInState() +// MARK: - Sign In Actions - switch action { - case _ as ResetSignInState: - state = SignInState() - case let action as SetLastSignInMethod: - state.lastSignIn = action.payload - state.lastSignInDate = Date.ISOStringFromDate(date: NetworkTimeManager.shared.currentTime ?? Date()) - default: - break +extension StateStore { + /// Set the last sign in method. + func setLastSignInMethod(_ method: SignInMethodTypes) async { + await mutate { state in + state.signIn.lastSignIn = method + state.signIn.lastSignInDate = Date.ISOStringFromDate(date: NetworkTimeManager.shared.currentTime ?? Date()) + } } - - return state } diff --git a/Sources/Rownd/Models/Context/StateActor.swift b/Sources/Rownd/Models/Context/StateActor.swift new file mode 100644 index 0000000..32dcc68 --- /dev/null +++ b/Sources/Rownd/Models/Context/StateActor.swift @@ -0,0 +1,377 @@ +// +// StateActor.swift +// Rownd +// +// Core actor-based state container providing thread-safe state management. +// Uses a lock for synchronous reads and actor isolation for mutations. +// + +import Foundation +import os + +// MARK: - State Lock + +/// A thread-safe lock wrapper for synchronous state access. +/// Uses os_unfair_lock for high-performance, low-level locking. +final class StateLock: @unchecked Sendable { + private var _value: Value + private let lock = OSAllocatedUnfairLock() + + init(_ value: Value) { + self._value = value + } + + /// Read the current value synchronously and thread-safely. + var value: Value { + lock.lock() + defer { lock.unlock() } + return _value + } + + /// Update the value synchronously and thread-safely. + func withLock(_ body: (inout Value) throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body(&_value) + } +} + +// MARK: - State Change + +/// Represents a state change with old and new values. +public struct StateChange: Sendable where T: Sendable { + public let old: T + public let new: T +} + +// MARK: - Subscription Token + +/// A token that can be used to cancel a subscription. +public final class SubscriptionToken: Sendable { + private let onCancel: @Sendable () -> Void + private let _isCancelled = StateLock(false) + + public var isCancelled: Bool { + _isCancelled.value + } + + init(onCancel: @escaping @Sendable () -> Void) { + self.onCancel = onCancel + } + + public func cancel() { + let shouldCancel = _isCancelled.withLock { cancelled -> Bool in + if cancelled { return false } + cancelled = true + return true + } + if shouldCancel { + onCancel() + } + } + + deinit { + cancel() + } +} + +// MARK: - Subscriber + +/// Internal type representing a state subscriber. +struct Subscriber: Sendable { + let id: UUID + let continuation: AsyncStream.Continuation +} + +// MARK: - State Actor + +/// The core state management actor. +/// Provides thread-safe state access and mutation with subscription support. +public actor StateActor { + // MARK: - Properties + + /// The current state, accessible synchronously via the lock. + private let stateLock: StateLock + + /// Subscribers organized by key path identifier. + private var subscribers: [String: [Any]] = [:] + + /// Middleware to execute on state changes. + private var middleware: [(RowndState, RowndState) async -> Void] = [] + + /// Logger for state operations. + private let log = Logger(subsystem: "io.rownd.sdk", category: "state") + + // MARK: - Initialization + + public init(initialState: RowndState = RowndState()) { + self.stateLock = StateLock(initialState) + } + + // MARK: - Synchronous State Access + + /// Get the current state synchronously from any thread. + /// This is safe to call from non-async contexts. + public nonisolated var state: RowndState { + stateLock.value + } + + /// Get a specific slice of state synchronously. + public nonisolated func getState(_ keyPath: KeyPath) -> T { + stateLock.value[keyPath: keyPath] + } + + // MARK: - State Mutation + + /// Update the state with a mutation closure. + /// All mutations are serialized through the actor. + @discardableResult + public func update(_ keyPath: WritableKeyPath, value: T) async -> RowndState { + let oldState = stateLock.value + let newState = stateLock.withLock { state -> RowndState in + state[keyPath: keyPath] = value + state.lastUpdateTs = Date() + return state + } + + await notifySubscribers(oldState: oldState, newState: newState) + await runMiddleware(oldState: oldState, newState: newState) + + return newState + } + + /// Update the state with a mutation closure for complex updates. + @discardableResult + public func mutate(_ mutation: (inout RowndState) -> Void) async -> RowndState { + let oldState = stateLock.value + let newState = stateLock.withLock { state -> RowndState in + mutation(&state) + state.lastUpdateTs = Date() + return state + } + + await notifySubscribers(oldState: oldState, newState: newState) + await runMiddleware(oldState: oldState, newState: newState) + + return newState + } + + /// Replace the entire state (used for initialization/reload). + @discardableResult + public func replaceState(_ newState: RowndState) async -> RowndState { + let oldState = stateLock.value + stateLock.withLock { state in + state = newState + } + + await notifySubscribers(oldState: oldState, newState: newState) + await runMiddleware(oldState: oldState, newState: newState) + + return newState + } + + // MARK: - Subscriptions + + /// Subscribe to changes in a specific state slice using AsyncStream. + public func subscribe( + to keyPath: KeyPath + ) -> (stream: AsyncStream, token: SubscriptionToken) { + let id = UUID() + let keyPathId = String(describing: keyPath) + + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { cont in + continuation = cont + // Emit current value immediately + cont.yield(self.stateLock.value[keyPath: keyPath]) + } + + let subscriber = Subscriber(id: id, continuation: continuation) + + // Store subscriber + if subscribers[keyPathId] == nil { + subscribers[keyPathId] = [] + } + subscribers[keyPathId]?.append(subscriber) + + // Create cancellation token + let token = SubscriptionToken { [weak self] in + Task { [weak self] in + await self?.removeSubscriber(id: id, keyPathId: keyPathId) + } + } + + return (stream, token) + } + + /// Subscribe to the entire state. + public func subscribeToAll() -> (stream: AsyncStream, token: SubscriptionToken) { + let id = UUID() + let keyPathId = "__all__" + + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { cont in + continuation = cont + cont.yield(self.stateLock.value) + } + + let subscriber = Subscriber(id: id, continuation: continuation) + + if subscribers[keyPathId] == nil { + subscribers[keyPathId] = [] + } + subscribers[keyPathId]?.append(subscriber) + + let token = SubscriptionToken { [weak self] in + Task { [weak self] in + await self?.removeSubscriber(id: id, keyPathId: keyPathId) + } + } + + return (stream, token) + } + + private func removeSubscriber(id: UUID, keyPathId: String) { + subscribers[keyPathId]?.removeAll { subscriber in + if let sub = subscriber as? Subscriber { + if sub.id == id { + sub.continuation.finish() + return true + } + } + // For type-erased subscribers, we check by casting to known types + return removeTypedSubscriber(subscriber, id: id) + } + } + + private func removeTypedSubscriber(_ subscriber: Any, id: UUID) -> Bool { + // Check common state types + if let sub = subscriber as? Subscriber, sub.id == id { + sub.continuation.finish() + return true + } + if let sub = subscriber as? Subscriber, sub.id == id { + sub.continuation.finish() + return true + } + if let sub = subscriber as? Subscriber, sub.id == id { + sub.continuation.finish() + return true + } + if let sub = subscriber as? Subscriber, sub.id == id { + sub.continuation.finish() + return true + } + if let sub = subscriber as? Subscriber, sub.id == id { + sub.continuation.finish() + return true + } + if let sub = subscriber as? Subscriber, sub.id == id { + sub.continuation.finish() + return true + } + if let sub = subscriber as? Subscriber, sub.id == id { + sub.continuation.finish() + return true + } + return false + } + + // MARK: - Middleware + + /// Add middleware that runs after each state change. + public func addMiddleware(_ handler: @escaping (RowndState, RowndState) async -> Void) { + middleware.append(handler) + } + + private func runMiddleware(oldState: RowndState, newState: RowndState) async { + for handler in middleware { + await handler(oldState, newState) + } + } + + // MARK: - Notification + + private func notifySubscribers(oldState: RowndState, newState: RowndState) async { + // Notify all-state subscribers + if let allSubscribers = subscribers["__all__"] { + for subscriber in allSubscribers { + if let sub = subscriber as? Subscriber { + sub.continuation.yield(newState) + } + } + } + + // Notify specific keypath subscribers + await notifyKeyPathSubscribers(oldState: oldState, newState: newState) + } + + private func notifyKeyPathSubscribers(oldState: RowndState, newState: RowndState) async { + // Auth state + if oldState.auth != newState.auth { + notifyTypedSubscribers(keyPathId: String(describing: \RowndState.auth), value: newState.auth) + } + + // User state + if oldState.user != newState.user { + notifyTypedSubscribers(keyPathId: String(describing: \RowndState.user), value: newState.user) + } + + // App config state + if oldState.appConfig != newState.appConfig { + notifyTypedSubscribers(keyPathId: String(describing: \RowndState.appConfig), value: newState.appConfig) + } + + // Passkey state + if oldState.passkeys != newState.passkeys { + notifyTypedSubscribers(keyPathId: String(describing: \RowndState.passkeys), value: newState.passkeys) + } + + // Sign in state + if oldState.signIn != newState.signIn { + notifyTypedSubscribers(keyPathId: String(describing: \RowndState.signIn), value: newState.signIn) + } + + // Clock sync state + if oldState.clockSyncState != newState.clockSyncState { + notifyTypedSubscribers(keyPathId: String(describing: \RowndState.clockSyncState), value: newState.clockSyncState) + } + + // isStateLoaded + if oldState.isStateLoaded != newState.isStateLoaded { + notifyTypedSubscribers(keyPathId: String(describing: \RowndState.isStateLoaded), value: newState.isStateLoaded) + } + } + + private func notifyTypedSubscribers(keyPathId: String, value: T) { + guard let subs = subscribers[keyPathId] else { return } + for subscriber in subs { + if let sub = subscriber as? Subscriber { + sub.continuation.yield(value) + } + } + } +} + +// MARK: - OSAllocatedUnfairLock Backport + +/// Backport of OSAllocatedUnfairLock for iOS 14-15. +/// Uses os_unfair_lock under the hood. +@available(iOS 14.0, macOS 11.0, *) +final class OSAllocatedUnfairLock: @unchecked Sendable { + private var _lock = os_unfair_lock() + + func lock() { + os_unfair_lock_lock(&_lock) + } + + func unlock() { + os_unfair_lock_unlock(&_lock) + } + + func withLock(_ body: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Sources/Rownd/Models/Context/StateStore.swift b/Sources/Rownd/Models/Context/StateStore.swift new file mode 100644 index 0000000..836c18d --- /dev/null +++ b/Sources/Rownd/Models/Context/StateStore.swift @@ -0,0 +1,345 @@ +// +// StateStore.swift +// Rownd +// +// Public-facing state management API that wraps StateActor. +// Provides multiple subscription mechanisms and handles persistence. +// + +import AnyCodable +import Combine +import Foundation +import SwiftUI +import os + +// MARK: - State Store + +/// The main public interface for Rownd state management. +/// Provides synchronous state access, async mutations, and multiple subscription APIs. +public final class StateStore: @unchecked Sendable { + // MARK: - Properties + + /// The underlying state actor. + private let actor: StateActor + + /// Logger for state operations. + private let log = Logger(subsystem: "io.rownd.sdk", category: "store") + + /// Storage key for persisted state. + private let storageKey = "RowndState" + + /// Debouncer for save operations. + private let saveDebouncer = Debouncer(delay: 0.1) + + /// Active subscription tokens for cleanup. + private var subscriptionTokens: [SubscriptionToken] = [] + + /// Lock for subscription token management. + private let tokenLock = OSAllocatedUnfairLock() + + // MARK: - Combine Support + + /// Subject for broadcasting state changes to Combine subscribers. + private let stateSubject = PassthroughSubject() + + /// Current value subject for immediate access. + private let currentStateSubject: CurrentValueSubject + + // MARK: - Initialization + + public init(initialState: RowndState = RowndState()) { + self.actor = StateActor(initialState: initialState) + self.currentStateSubject = CurrentValueSubject(initialState) + + // Set up middleware for persistence and Combine broadcasting + Task { + await actor.addMiddleware { [weak self] oldState, newState in + await self?.handleStateChange(oldState: oldState, newState: newState) + } + } + } + + // MARK: - Synchronous State Access + + /// Get the current state synchronously from any thread. + public var state: RowndState { + actor.state + } + + /// Subscript access to state slices. + public subscript(keyPath: KeyPath) -> T { + actor.getState(keyPath) + } + + // MARK: - State Mutation + + /// Update a specific state property. + @discardableResult + public func update(_ keyPath: WritableKeyPath, value: T) async -> RowndState { + await actor.update(keyPath, value: value) + } + + /// Perform a complex state mutation. + @discardableResult + public func mutate(_ mutation: @escaping (inout RowndState) -> Void) async -> RowndState { + await actor.mutate(mutation) + } + + /// Replace the entire state (used for initialization/reload). + @discardableResult + public func replaceState(_ newState: RowndState) async -> RowndState { + await actor.replaceState(newState) + } + + // MARK: - AsyncStream Subscriptions + + /// Subscribe to changes in a specific state slice using AsyncStream. + /// - Parameter keyPath: The key path to observe + /// - Returns: An AsyncStream that emits values when the state slice changes + public func stream( + _ keyPath: KeyPath + ) async -> AsyncStream { + let (stream, token) = await actor.subscribe(to: keyPath) + storeToken(token) + return stream + } + + /// Subscribe to the entire state using AsyncStream. + public func streamAll() async -> AsyncStream { + let (stream, token) = await actor.subscribeToAll() + storeToken(token) + return stream + } + + // MARK: - Combine Subscriptions + + /// Get a Combine publisher for a specific state slice. + /// - Parameter keyPath: The key path to observe + /// - Returns: A publisher that emits values when the state slice changes + public func publisher( + for keyPath: KeyPath + ) -> AnyPublisher { + currentStateSubject + .map(keyPath) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + /// Get a Combine publisher for the entire state. + public func publisher() -> AnyPublisher { + currentStateSubject + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + // MARK: - Persistence + + /// Load state from persistent storage. + @discardableResult + public func load() async -> RowndState { + guard let existingStateStr = Storage.shared.get(forKey: storageKey) else { + await mutate { $0.isStateLoaded = true } + return state + } + + do { + let decoder = JSONDecoder() + var decoded = try decoder.decode( + RowndState.self, + from: existingStateStr.data(using: .utf8) ?? Data() + ) + decoded.isStateLoaded = true + decoded.clockSyncState = + NetworkTimeManager.shared.currentTime != nil ? .synced : state.clockSyncState + + return await replaceState(decoded) + } catch { + log.debug("Failed decoding state from storage: \(String(describing: error))") + await mutate { $0.isStateLoaded = true } + return state + } + } + + /// Reload state from persistent storage if it has changed. + public func reload() async { + guard let existingStateStr = Storage.shared.get(forKey: storageKey) else { + return + } + + do { + let decoder = JSONDecoder() + let decoded = try decoder.decode( + RowndState.self, + from: existingStateStr.data(using: .utf8) ?? Data() + ) + + // Only reload if the timestamp is different + if decoded.lastUpdateTs.timeIntervalSinceReferenceDate + != state.lastUpdateTs.timeIntervalSinceReferenceDate + { + await mutate { state in + state = decoded + state.clockSyncState = self.state.clockSyncState + state.isStateLoaded = true + } + } + } catch { + log.debug( + "Failed decoding state from storage during reload: \(String(describing: error))") + } + } + + /// Save state to persistent storage. + private func save(_ state: RowndState) { + saveDebouncer.debounce { [weak self, state] in + guard let self = self else { return } + if let encoded = try? state.toJson() { + Storage.shared.set(encoded, forKey: self.storageKey) + DarwinNotificationManager.shared.postNotification( + name: "io.rownd.events.StateUpdated") + self.log.trace("Wrote state to storage") + } + } + } + + // MARK: - Internal + + private func handleStateChange(oldState: RowndState, newState: RowndState) async { + // Update Combine subjects + await MainActor.run { + currentStateSubject.send(newState) + stateSubject.send(newState) + } + + // Persist state (skip for certain state-only changes) + if shouldPersist(oldState: oldState, newState: newState) { + save(newState) + } + } + + private func shouldPersist(oldState: RowndState, newState: RowndState) -> Bool { + // Don't persist if only clockSyncState or isStateLoaded changed + var oldForComparison = oldState + var newForComparison = newState + oldForComparison.clockSyncState = .unknown + newForComparison.clockSyncState = .unknown + oldForComparison.isStateLoaded = false + newForComparison.isStateLoaded = false + oldForComparison.lastUpdateTs = Date.distantPast + newForComparison.lastUpdateTs = Date.distantPast + + return oldForComparison != newForComparison + } + + private func storeToken(_ token: SubscriptionToken) { + tokenLock.withLock { + subscriptionTokens.append(token) + // Clean up cancelled tokens + subscriptionTokens.removeAll { $0.isCancelled } + } + } + + /// Cancel all active subscriptions. + public func cancelAllSubscriptions() { + tokenLock.withLock { + for token in subscriptionTokens { + token.cancel() + } + subscriptionTokens.removeAll() + } + } + + deinit { + cancelAllSubscriptions() + } +} + +// MARK: - Convenience Extensions + +extension StateStore { + // MARK: - Auth State + + /// Update the authentication state. + public func setAuth(_ auth: AuthState) async { + await update(\.auth, value: auth) + } + + /// Clear authentication (sign out). + public func clearAuth() async { + await update(\.auth, value: AuthState()) + } + + // MARK: - User State + + /// Update the user state. + public func setUser(_ user: UserState) async { + await update(\.user, value: user) + } + + /// Update user data. + public func setUserData(_ data: [String: AnyCodable]) async { + await mutate { state in + state.user.data = data + state.user.isLoading = false + } + } + + /// Set user loading state. + public func setUserLoading(_ isLoading: Bool) async { + await update(\.user.isLoading, value: isLoading) + } + + // MARK: - App Config State + + /// Update the app configuration. + public func setAppConfig(_ config: AppConfigState) async { + await update(\.appConfig, value: config) + } + + // MARK: - Clock Sync + + /// Update clock sync state. + internal func setClockSync(_ clockSyncState: ClockSyncState) async { + await update(\.clockSyncState, value: clockSyncState) + } + + // MARK: - Passkeys + + /// Update passkey state. + public func setPasskeys(_ passkeys: PasskeyState) async { + await update(\.passkeys, value: passkeys) + } + + // MARK: - Sign In + + /// Update sign in state. + public func setSignIn(_ signIn: SignInState) async { + await update(\.signIn, value: signIn) + } + + /// Reset sign in state. + public func resetSignIn() async { + await update(\.signIn, value: SignInState()) + } +} + +// MARK: - SwiftUI Support + +extension StateStore { + /// Create an observable state wrapper for SwiftUI. + /// This returns an ObservableState that can be used with @StateObject. + public func subscribe( + _ selector: @escaping (RowndState) -> T + ) -> LegacyObservableState { + LegacyObservableState(store: self, selector: selector) + } + + /// Create a throttled observable state wrapper for SwiftUI. + public func subscribeThrottled( + _ selector: @escaping (RowndState) -> T, + throttleInMs: Int = 350 + ) -> LegacyObservableThrottledState { + LegacyObservableThrottledState(store: self, selector: selector, throttleInMs: throttleInMs) + } +} diff --git a/Sources/Rownd/Models/Context/User.swift b/Sources/Rownd/Models/Context/User.swift index 8710a31..9176db6 100644 --- a/Sources/Rownd/Models/Context/User.swift +++ b/Sources/Rownd/Models/Context/User.swift @@ -1,5 +1,5 @@ // -// Auth.swift +// User.swift // framework // // Created by Matt Hamann on 7/8/22. @@ -9,20 +9,18 @@ import AnyCodable import Foundation import Get import OSLog -import ReSwift -import ReSwiftThunk import UIKit private let log = Logger(subsystem: "io.rownd.sdk", category: "user") public typealias UserStateData = [String: AnyCodable] -public enum UserStateVal: String, Codable, Hashable { +public enum UserStateVal: String, Codable, Hashable, Sendable { case enabled = "enabled" case disabled = "disabled" } -public enum UserAuthLevel: String, Codable, Hashable { +public enum UserAuthLevel: String, Codable, Hashable, Sendable { case instant = "instant" case guest = "guest" case unverified = "unverified" @@ -30,7 +28,7 @@ public enum UserAuthLevel: String, Codable, Hashable { case unknown = "unknown" } -public struct UserState: Hashable { +public struct UserState: Hashable, Sendable { public var isLoading: Bool = false public var isErrored: Bool = false public var errorMessage: String? @@ -82,76 +80,35 @@ extension UserState: Codable { } public func set(data: [String: AnyCodable]) { - DispatchQueue.main.async { - Context.currentContext.store.dispatch(UserData.save(data)) + Task { + await UserData.save(data) } } public func set(field: String, value: AnyCodable) { var userData = self.data userData[field] = value - DispatchQueue.main.async { - Context.currentContext.store.dispatch(UserData.save(userData)) + Task { + await UserData.save(userData) } } internal func setMetaData(_ meta: [String: AnyCodable]) { - DispatchQueue.main.async { - Context.currentContext.store.dispatch(UserData.saveMetaData(meta)) + Task { + await UserData.saveMetaData(meta) } } internal func setMetaData(field: String, value: AnyCodable) { var meta = self.meta ?? [:] meta[field] = value - DispatchQueue.main.async { - Context.currentContext.store.dispatch(UserData.saveMetaData(meta)) + Task { + await UserData.saveMetaData(meta) } } } -struct SetUserLoading: Action { - var isLoading: Bool -} - -struct SetUserData: Action { - var data: [String: AnyCodable] = [:] - var meta: [String: AnyCodable]? = [:] -} - -struct SetUserError: Action { - var isErrored: Bool = true - var errorMessage: String -} - -struct SetUserState: Action { - var payload: UserState -} - -func userReducer(action: Action, state: UserState?) -> UserState { - var state = state ?? UserState() - - switch action { - case let action as SetUserState: - state = action.payload - case let action as SetUserData: - state.data = action.data - state.meta = action.meta ?? [:] - state.isLoading = false - case let action as SetUserLoading: - state.isLoading = action.isLoading - case let action as SetAuthState: - if !action.payload.isAuthenticated { - state = UserState() - } - default: - break - } - - return state -} - -/* API / side-effecty things */ +// MARK: - API / side-effect actions // Easily unwrap the main payload from the `app` key struct UserDataPayload: Codable { @@ -162,7 +119,7 @@ struct UserMetaDataPayload: Codable { var meta: [String: AnyCodable] } -public struct UserStateResponse: Hashable, Codable { +public struct UserStateResponse: Hashable, Codable, Sendable { public var data: UserStateData = [:] public var meta: UserStateData? = [:] public var state: UserStateVal = .enabled @@ -174,7 +131,7 @@ public struct UserStateResponse: Hashable, Codable { } } -public struct UserMetaDataResponse: Hashable { +public struct UserMetaDataResponse: Hashable, Sendable { public var id: String = "" public var meta: [String: AnyCodable] = [:] } @@ -199,22 +156,23 @@ extension UserStateResponse { class UserData { private static var fetchTask: Task? - static func onReceiveUserData(_ action: SetUserData) -> Thunk { - return Thunk { dispatch, getState in - guard getState() != nil else { return } - DispatchQueue.main.async { - dispatch(action) - } + /// Handle receiving user data from external source. + static func onReceiveUserData(data: [String: AnyCodable], meta: [String: AnyCodable]? = nil) async { + await Context.currentContext.store.mutate { state in + state.user.data = data + state.user.meta = meta ?? state.user.meta + state.user.isLoading = false } } - internal static func fetchUserData(_ state: RowndState) async throws -> UserStateResponse? { + internal static func fetchUserData() async throws -> UserStateResponse? { if let handle = fetchTask { log.debug("User data fetch is already in progress") return try await handle.value } let task = Task.retrying { () throws -> UserStateResponse? in + let state = Context.currentContext.store.state guard state.auth.isAuthenticated else { throw RowndError("User must be authenticated before fetching profile") @@ -252,7 +210,7 @@ class UserData { } throw RowndError( - "Failed to retireve user: \(error.localizedDescription)" + "Failed to retrieve user: \(error.localizedDescription)" ) } } @@ -262,137 +220,112 @@ class UserData { return try await task.value } - static func fetch() -> Thunk { - return Thunk { dispatch, getState in - guard let state = getState() else { return } + static func fetch() async { + let state = Context.currentContext.store.state - Task { - guard state.auth.isAuthenticated else { - return - } + guard state.auth.isAuthenticated else { + return + } - DispatchQueue.main.async { - dispatch(SetUserLoading(isLoading: true)) - } + await Context.currentContext.store.setUserLoading(true) - defer { - DispatchQueue.main.async { - dispatch(SetUserLoading(isLoading: false)) - } - } + defer { + Task { + await Context.currentContext.store.setUserLoading(false) + } + } - do { - let userResponse = try await fetchUserData(state) - - guard let userResponse = userResponse else { - return - } - - Task { @MainActor in - dispatch( - SetUserState( - payload: userResponse.toUserState() - )) - } - } catch { - log.error( - "Something went wrong while fetching the user's profile \(String(describing: error))" - ) - } + do { + let userResponse = try await fetchUserData() + + guard let userResponse = userResponse else { + return } + + await Context.currentContext.store.setUser(userResponse.toUserState()) + } catch { + log.error( + "Something went wrong while fetching the user's profile \(String(describing: error))" + ) } } - static func save() -> Thunk { - return save(Context.currentContext.store.state.user.data) + static func save() async { + await save(Context.currentContext.store.state.user.data) } - static func save(_ data: [String: AnyCodable]) -> Thunk { - return Thunk { dispatch, getState in - guard let state = getState() else { return } - guard !state.user.isLoading else { return } + static func save(_ data: [String: AnyCodable]) async { + let state = Context.currentContext.store.state + guard !state.user.isLoading else { return } - DispatchQueue.main.async { - dispatch(SetUserData(data: data, meta: state.user.meta)) - } + // Update local state immediately + await Context.currentContext.store.mutate { state in + state.user.data = data + } + + guard state.auth.isAuthenticated else { + return + } + + await Context.currentContext.store.setUserLoading(true) + defer { Task { - guard state.auth.isAuthenticated else { - return - } + await Context.currentContext.store.setUserLoading(false) + } + } - DispatchQueue.main.async { - dispatch(SetUserLoading(isLoading: true)) - } + let userDataPayload = UserDataPayload(data: data) - defer { - DispatchQueue.main.async { - dispatch(SetUserLoading(isLoading: false)) - } - } + do { + let user = try await Rownd.apiClient.send( + Request( + path: "/me/applications/\(state.appConfig.id ?? "unknown")/data", + method: .put, + body: userDataPayload + ) + ).value - // Handle data that should be encrypted - var updatedUserState = UserState() - updatedUserState.data = data - - let userDataPayload = UserDataPayload(data: data) - - do { - let user = try await Rownd.apiClient.send( - Request( - path: "/me/applications/\(state.appConfig.id ?? "unknown")/data", - method: .put, - body: userDataPayload - ) - ).value - - logger.debug("Decoded user response: \(String(describing: user))") - - DispatchQueue.main.async { - dispatch(SetUserData(data: user?.data ?? [:], meta: state.user.meta)) - } - } catch { - logger.error("Failed to save user profile: \(String(describing: error))") - DispatchQueue.main.async { - dispatch( - SetUserError( - errorMessage: - "The user profile could not be saved: \(String(describing: error))" - )) - } - } + logger.debug("Decoded user response: \(String(describing: user))") + + await Context.currentContext.store.mutate { state in + state.user.data = user?.data ?? [:] + state.user.isLoading = false + } + } catch { + logger.error("Failed to save user profile: \(String(describing: error))") + await Context.currentContext.store.mutate { state in + state.user.isErrored = true + state.user.errorMessage = "The user profile could not be saved: \(String(describing: error))" } } } - static func saveMetaData(_ meta: [String: AnyCodable]) -> Thunk { - return Thunk { dispatch, getState in - guard let state = getState() else { return } - guard !state.user.isLoading else { return } + static func saveMetaData(_ meta: [String: AnyCodable]) async { + let state = Context.currentContext.store.state + guard !state.user.isLoading else { return } - DispatchQueue.main.async { - dispatch(SetUserData(data: state.user.data, meta: meta)) - } + // Update local state immediately + await Context.currentContext.store.mutate { state in + state.user.meta = meta + } - Task { - guard state.auth.isAuthenticated else { - return - } + guard state.auth.isAuthenticated else { + return + } - do { - let response = try await Rownd.apiClient.send( - Request( - path: "/me/meta", - method: .put, - body: UserMetaDataPayload(meta: meta) - ) - ).value - - logger.debug("Saved Rownd meta data: \(String(describing: response))") - } catch { - logger.error("Failed to save meta data: \(String(describing: error))") - } - } + do { + let response = try await Rownd.apiClient.send( + Request( + path: "/me/meta", + method: .put, + body: UserMetaDataPayload(meta: meta) + ) + ).value + + logger.debug("Saved Rownd meta data: \(String(describing: response))") + } catch { + logger.error("Failed to save meta data: \(String(describing: error))") } } } diff --git a/Sources/Rownd/Models/GoogleSignInCoordinator.swift b/Sources/Rownd/Models/GoogleSignInCoordinator.swift index ff0bb73..0990721 100644 --- a/Sources/Rownd/Models/GoogleSignInCoordinator.swift +++ b/Sources/Rownd/Models/GoogleSignInCoordinator.swift @@ -5,11 +5,11 @@ // Created by Matt Hamann on 4/4/23. // +import AnyCodable import Foundation import GoogleSignIn -import UIKit -import AnyCodable import JWTDecode +import UIKit class GoogleSignInCoordinator: NSObject { var parent: Rownd @@ -29,7 +29,7 @@ class GoogleSignInCoordinator: NSObject { Rownd.requestSignIn(RowndSignInOptions(intent: intent)) } - /// Sign in funciton for customer-provided web views + /// Sign in function for customer-provided web views func signIn(webViewId: String, intent: RowndSignInIntent?, hint: String?) -> Void { let googleConfig = Context.currentContext.store.state.appConfig.config?.hub?.auth?.signInMethods?.google @@ -47,32 +47,32 @@ class GoogleSignInCoordinator: NSObject { logger.error("Failed to retrieve root view controller") return } - + do { let result = try await GIDSignIn.sharedInstance.signIn( withPresenting: rootViewController, hint: hint ) - + guard let idToken = result.user.idToken else { Rownd.customerWebViews.evaluateJavaScript(webViewId: webViewId, code: "window.rownd.requestSignIn({ 'login_step': 'error', 'sign_in_type': 'google' });") logger.error("Google sign-in failed. Either no ID token was present, or an error was thrown.") return } - + logger.debug("Sign-in handshake with Google completed successfully.") do { Rownd.customerWebViews.evaluateJavaScript(webViewId: webViewId, code: "window.rownd.requestSignIn({ 'login_step': 'completing' });") - + let tokenResponse = try await Auth.fetchToken(idToken: idToken.tokenString, userData: nil, intent: intent) - + guard let tokenResponse = tokenResponse, let accessToken = tokenResponse.accessToken, let refreshToken = tokenResponse.refreshToken else { logger.error("Token response is empty") return } - + // Reload the web view page with rph_init appended to the URL fragment in order // to complete the sign-in do { @@ -81,14 +81,14 @@ class GoogleSignInCoordinator: NSObject { return $0.starts(with: "app:") })?.replacingOccurrences(of: "app:", with: "") let appUserId = jwt.claim(name: "https://auth.rownd.io/app_user_id") - + let rphInit = RphInit( accessToken: accessToken, refreshToken: refreshToken, appId: appId, appUserId: appUserId.string ) - + let rphInitString = try rphInit.valueForURLFragment() Rownd.customerWebViews.evaluateJavaScript(webViewId: webViewId, code: """ let url = new URL(window.location.href); @@ -177,34 +177,33 @@ class GoogleSignInCoordinator: NSObject { intent: intent ) - Task { @MainActor in - Context.currentContext.store.dispatch(SetAuthState( - payload: AuthState( - accessToken: tokenResponse?.accessToken, - refreshToken: tokenResponse?.refreshToken - ) - )) - Context.currentContext.store.dispatch(UserData.fetch()) - Context.currentContext.store.dispatch(SetLastSignInMethod(payload: SignInMethodTypes.google)) - - Rownd.requestSignIn( - jsFnOptions: RowndSignInJsOptions( - loginStep: .success, - intent: intent, - userType: tokenResponse?.userType, - appVariantUserType: tokenResponse?.appVariantUserType - ) + // Update auth state + await Context.currentContext.store.setAuth(AuthState( + accessToken: tokenResponse?.accessToken, + refreshToken: tokenResponse?.refreshToken + )) + + await UserData.fetch() + await Context.currentContext.store.setLastSignInMethod(.google) + + Rownd.requestSignIn( + jsFnOptions: RowndSignInJsOptions( + loginStep: .success, + intent: intent, + userType: tokenResponse?.userType, + appVariantUserType: tokenResponse?.appVariantUserType ) - - RowndEventEmitter.emit(RowndEvent( - event: .signInCompleted, - data: [ - "method": AnyCodable(SignInType.google.rawValue), - "user_type": AnyCodable(tokenResponse?.userType?.rawValue), - "app_variant_user_type": AnyCodable(tokenResponse?.appVariantUserType?.rawValue) - ] - )) - } + ) + + RowndEventEmitter.emit(RowndEvent( + event: .signInCompleted, + data: [ + "method": AnyCodable(SignInType.google.rawValue), + "user_type": AnyCodable(tokenResponse?.userType?.rawValue), + "app_variant_user_type": AnyCodable(tokenResponse?.appVariantUserType?.rawValue) + ] + )) + return } catch ApiError.generic(let errorInfo) { if errorInfo.code == "E_SIGN_IN_USER_NOT_FOUND" { diff --git a/Sources/Rownd/Models/PasskeyCoordinator.swift b/Sources/Rownd/Models/PasskeyCoordinator.swift index 93e870d..7c11285 100644 --- a/Sources/Rownd/Models/PasskeyCoordinator.swift +++ b/Sources/Rownd/Models/PasskeyCoordinator.swift @@ -1,9 +1,9 @@ -import AuthenticationServices import AnyCodable +import AuthenticationServices import Foundation -import os import Get import LocalAuthentication +import os extension Data { init?(base64EncodedURLSafe string: String, options: Base64DecodingOptions = []) { @@ -310,15 +310,12 @@ internal class PasskeyCoordinator: NSObject, ASAuthorizationControllerPresentati ) ).value - DispatchQueue.main.async { - Context.currentContext.store.dispatch(SetAuthState( - payload: AuthState( - accessToken: challengeAuthenticationCompleteResponse.access_token, - refreshToken: challengeAuthenticationCompleteResponse.refresh_token - ) - )) - Context.currentContext.store.dispatch(UserData.fetch()) - } + // Update auth state + await Context.currentContext.store.setAuth(AuthState( + accessToken: challengeAuthenticationCompleteResponse.access_token, + refreshToken: challengeAuthenticationCompleteResponse.refresh_token + )) + await UserData.fetch() await hubViewController?.loadNewPage( targetPage: .signIn, @@ -387,7 +384,6 @@ internal class PasskeyCoordinator: NSObject, ASAuthorizationControllerPresentati } @MainActor func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - let store = Context.currentContext.store let hubViewController = getHubViewController() if let authorizationError = error as? ASAuthorizationError { switch authorizationError.code { diff --git a/Sources/Rownd/Rownd.swift b/Sources/Rownd/Rownd.swift index 719bef2..518469e 100644 --- a/Sources/Rownd/Rownd.swift +++ b/Sources/Rownd/Rownd.swift @@ -12,7 +12,6 @@ import Get import GoogleSignIn import LBBottomSheet import LocalAuthentication -import ReSwift import SwiftUI import UIKit import WebKit @@ -39,6 +38,11 @@ public class Rownd: NSObject { Rownd.automationsCoordinator.processAutomations() } + /// Access the state store for subscriptions. + public static var state: StateStore { + return Context.currentContext.store + } + @discardableResult public static func configure( launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil, appKey: String? @@ -47,11 +51,11 @@ public class Rownd: NSObject { config.appKey = _appKey } - let state = await inst.inflateStoreCache() + let loadedState = await inst.inflateStoreCache() // Skip the rest within app extensions if Bundle.main.bundlePath.hasSuffix(".appex") { - return state + return loadedState } await inst.loadAppConfig() @@ -62,11 +66,11 @@ public class Rownd: NSObject { Rownd.automationsCoordinator.start() } - let store = Context.currentContext.store - if store.state.isStateLoaded && !store.state.auth.isAuthenticated { + let currentState = Context.currentContext.store.state + if currentState.isStateLoaded && !currentState.auth.isAuthenticated { SmartLinks.handleSmartLinkLaunchBehavior(launchOptions: launchOptions) - if store.state.appConfig.config?.hub?.auth?.signInMethods?.google?.enabled == true { + if currentState.appConfig.config?.hub?.auth?.signInMethods?.google?.enabled == true { do { _ = try await GIDSignIn.sharedInstance.restorePreviousSignIn() logger.debug("Successfully restored previous Google Sign-in") @@ -77,12 +81,12 @@ public class Rownd: NSObject { } // Check to see if we're handling an existing auth challenge - if store.state.auth.challengeId != nil && store.state.auth.userIdentifier != nil { + if currentState.auth.challengeId != nil && currentState.auth.userIdentifier != nil { Rownd.requestSignIn( jsFnOptions: RowndSignInJsOptions( loginStep: .completing, - challengeId: store.state.auth.challengeId, - userIdentifier: store.state.auth.userIdentifier + challengeId: currentState.auth.challengeId, + userIdentifier: currentState.auth.userIdentifier )) } @@ -90,17 +94,19 @@ public class Rownd: NSObject { // Fetch user if authenticated and app is in foreground await MainActor.run { - if store.state.auth.isAuthenticated && UIApplication.shared.applicationState == .active + if currentState.auth.isAuthenticated && UIApplication.shared.applicationState == .active { - store.dispatch(UserData.fetch()) - store.dispatch(PasskeyData.fetchPasskeyRegistration()) + Task { + await UserData.fetch() + await PasskeyData.fetchPasskeyRegistration() + } } } InstantUsers(context: Context.currentContext) .tmpForceInstantUserConversionIfRequested() - return state + return currentState } @available(*, deprecated, renamed: "handleSmartLink") @@ -189,8 +195,8 @@ public class Rownd: NSObject { ) { switch with { case .passkey: - let store = Context.currentContext.store - if store.state.auth.accessToken != nil { + let currentState = Context.currentContext.store.state + if currentState.auth.accessToken != nil { var passkeySignInOptions = RowndConnectPasskeySignInOptions( biometricType: LAContext().biometricType.rawValue ).dictionary() @@ -224,16 +230,13 @@ public class Rownd: NSObject { } public static func signOut() { - Task { @MainActor in - let store = Context.currentContext.store - store.dispatch(SetAuthState(payload: AuthState())) + Task { + await Context.currentContext.store.clearAuth() - Task { - RowndEventEmitter.emit( - RowndEvent( - event: .signOut - )) - } + RowndEventEmitter.emit( + RowndEvent( + event: .signOut + )) } } @@ -261,28 +264,27 @@ public class Rownd: NSObject { @discardableResult public static func getAccessToken(throwIfMissing: Bool = false) async throws -> String? { - let store = Context.currentContext.store - return try await store.state.auth.getAccessToken(throwIfMissing: throwIfMissing) + let currentState = Context.currentContext.store.state + return try await currentState.auth.getAccessToken(throwIfMissing: throwIfMissing) } @discardableResult public static func getAccessToken(token: String) async -> String? { guard let tokenResponse = try? await Auth.fetchToken(token) else { return nil } - Task { @MainActor in - let store = Context.currentContext.store - store.dispatch( - SetAuthState( - payload: AuthState( - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken))) - store.dispatch(UserData.fetch()) + Task { + await Context.currentContext.store.mutate { state in + state.auth.accessToken = tokenResponse.accessToken + state.auth.refreshToken = tokenResponse.refreshToken + } + await UserData.fetch() } return tokenResponse.accessToken } - public func state() -> Store { + /// Returns the state store for subscriptions (backward compatible). + public func state() -> StateStore { return Context.currentContext.store } @@ -335,12 +337,12 @@ public class Rownd: NSObject { internal static func determineSignInOptions( _ signInOptions: RowndSignInOptions?, signInType: SignInType? ) -> RowndSignInOptions? { - let store = Context.currentContext.store + let currentState = Context.currentContext.store.state var signInOptions = signInOptions if signInOptions?.intent == RowndSignInIntent.signUp || signInOptions?.intent == RowndSignInIntent.signIn { - if store.state.appConfig.config?.hub?.auth?.useExplicitSignUpFlow != true { + if currentState.appConfig.config?.hub?.auth?.useExplicitSignUpFlow != true { signInOptions?.intent = nil logger.error( "Sign in with intent: SignIn/SignUp is not enabled. Turn it on in the Rownd platform" @@ -361,20 +363,18 @@ public class Rownd: NSObject { } private func loadAppConfig() async { - let store = Context.currentContext.store - if store.state.appConfig.id == nil { + let currentState = Context.currentContext.store.state + if currentState.appConfig.id == nil { // Await the config if it wasn't already cached guard let appConfig = await AppConfig.fetch() else { return } - Task { @MainActor in - store.dispatch(SetAppConfig(payload: appConfig.app)) - } + await Context.currentContext.store.setAppConfig(appConfig.app) } else { - Task { @MainActor in - // Refresh in background if already present - store.dispatch(AppConfig.requestAppState()) + // Refresh in background if already present + Task { + await AppConfig.requestAppState() } } @@ -382,8 +382,7 @@ public class Rownd: NSObject { @discardableResult private func inflateStoreCache() async -> RowndState { - let store = Context.currentContext.store - return await store.state.load() + return await Context.currentContext.store.load() } private func displayHub(_ page: HubPageSelector) { @@ -440,7 +439,7 @@ public class Rownd: NSObject { } public class UserPropAccess { - private var store: Store { + private var store: StateStore { return Context.currentContext.store } public func get() -> UserState { diff --git a/Sources/Rownd/Views/AccountManager/AccountManagerView.swift b/Sources/Rownd/Views/AccountManager/AccountManagerView.swift index ddfc1c8..2e637b8 100644 --- a/Sources/Rownd/Views/AccountManager/AccountManagerView.swift +++ b/Sources/Rownd/Views/AccountManager/AccountManagerView.swift @@ -5,14 +5,14 @@ // Created by Matt Hamann on 7/13/22. // -import SwiftUI import AnyCodable +import SwiftUI struct AccountManager: View { - @StateObject var appConfig = Rownd.getInstance().state().subscribe { $0.appConfig } - @StateObject var userData = Rownd.getInstance().state().subscribe { $0.user.data } - @State var userIsLoading = Rownd.getInstance().state().subscribe { $0.user.isLoading } + @StateObject var appConfig = Rownd.getInstance().state().subscribe(select: { $0.appConfig }) + @StateObject var userData = Rownd.getInstance().state().subscribe(select: { $0.user.data }) + @State var userIsLoading = Rownd.getInstance().state().subscribe(select: { $0.user.isLoading }) @State private var editingUser: [String: AnyCodable] = [:] @@ -41,7 +41,9 @@ struct AccountManager: View { } Button(action: { let mergedData = editingUser.merging(userData.current) { (current, _) in current } - Context.currentContext.store.dispatch(UserData.save(mergedData)) + Task { + await UserData.save(mergedData) + } }) { Text(userIsLoading.current ? "Saving..." : "Save") } diff --git a/Sources/Rownd/Views/HubWebView/HubWebViewController.swift b/Sources/Rownd/Views/HubWebView/HubWebViewController.swift index 308ca57..b0241a9 100644 --- a/Sources/Rownd/Views/HubWebView/HubWebViewController.swift +++ b/Sources/Rownd/Views/HubWebView/HubWebViewController.swift @@ -6,11 +6,10 @@ // import Foundation +import LocalAuthentication +import SwiftUI import UIKit import WebKit -import SwiftUI -import LocalAuthentication -import ReSwiftThunk public enum HubPageSelector { case signIn @@ -292,18 +291,18 @@ extension HubWebViewController: WKScriptMessageHandler, WKNavigationDelegate { switch hubMessage.type { case .authentication: guard case .authentication(let authMessage) = hubMessage.payload else { return } - guard hubViewController?.targetPage == .signIn else { return } + guard hubViewController?.targetPage == .signIn else { return } let initialJsFunctionArgsAsJson = self.jsFunctionArgsAsJson - DispatchQueue.main.async { - // Ensure user.isLoading = false so that the data is fetched properly - store.dispatch(SetUserLoading(isLoading: false)) - // Then set our tokens - store.dispatch(store.state.auth.onReceiveAuthTokens( - AuthState(accessToken: authMessage.accessToken, refreshToken: authMessage.refreshToken) - )) - store.dispatch(ResetSignInState()) + + // Handle auth tokens + Task { + await store.setUserLoading(false) + let newAuthState = AuthState(accessToken: authMessage.accessToken, refreshToken: authMessage.refreshToken) + newAuthState.onReceiveAuthTokens(newAuthState) + await store.resetSignIn() } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in // .now() + num_seconds + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in // Close the hub as long as no other rownd api was called if initialJsFunctionArgsAsJson == self?.jsFunctionArgsAsJson { self?.hubViewController?.hide() @@ -316,11 +315,8 @@ extension HubWebViewController: WKScriptMessageHandler, WKNavigationDelegate { case .userDataUpdate: guard case .userDataUpdate(let userDataMessage) = hubMessage.payload else { return } guard hubViewController?.targetPage == .manageAccount else { return } - DispatchQueue.main.async { - store - .dispatch( - SetUserState(payload: userDataMessage.toUserState()) - ) + Task { + await store.setUser(userDataMessage.toUserState()) } case .triggerSignInWithApple: @@ -328,7 +324,6 @@ extension HubWebViewController: WKScriptMessageHandler, WKNavigationDelegate { if case .triggerSignInWithApple(let message) = hubMessage.payload { signInWithAppleMessage = message } - // self.hubViewController?.hide() Rownd.requestSignIn( with: .appleId, signInOptions: RowndSignInOptions( @@ -361,10 +356,10 @@ extension HubWebViewController: WKScriptMessageHandler, WKNavigationDelegate { if hubViewController?.targetPage != .signOut && signOutMessage?.wasUserInitiated != true { - return; + return } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in // .now() + num_seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.hubViewController?.hide() } Rownd.signOut() @@ -398,26 +393,21 @@ extension HubWebViewController: WKScriptMessageHandler, WKNavigationDelegate { break case .authChallengeInitiated: guard case .authChallengeInitiated(let authChallengeMessage) = hubMessage.payload else { return } - DispatchQueue.main.async { - var newAuthState = Context.currentContext.store.state.auth - newAuthState.challengeId = authChallengeMessage.challengeId - newAuthState.userIdentifier = authChallengeMessage.userIdentifier - Context.currentContext.store.dispatch( - SetAuthState(payload: newAuthState) - ) + Task { + await store.mutate { state in + state.auth.challengeId = authChallengeMessage.challengeId + state.auth.userIdentifier = authChallengeMessage.userIdentifier + } } break case .authChallengeCleared: - DispatchQueue.main.async { - var newAuthState = Context.currentContext.store.state.auth - newAuthState.challengeId = nil - newAuthState.userIdentifier = nil - - Context.currentContext.store.dispatch( - SetAuthState(payload: newAuthState) - ) + Task { + await store.mutate { state in + state.auth.challengeId = nil + state.auth.userIdentifier = nil + } } - break; + break } } catch { logger.debug("Failed to decode incoming interop message: \(String(describing: error))") diff --git a/Sources/Rownd/framework/Authenticator.swift b/Sources/Rownd/framework/Authenticator.swift index 19aa255..a28ef65 100644 --- a/Sources/Rownd/framework/Authenticator.swift +++ b/Sources/Rownd/framework/Authenticator.swift @@ -10,7 +10,6 @@ import Factory import Foundation import Get import OSLog -import ReSwift private let log = Logger(subsystem: "io.rownd.sdk", category: "authenticator") @@ -89,41 +88,30 @@ private class TokenApiClientDelegate: APIClientDelegate { } } -// This class exists for the sole purpose of subscribing the Authenticator to the -// global state. Data races can occur when using subscribers within the actor itself, -// which leads to memmory corruption and weird app crashes. +// This class exists for the sole purpose of maintaining a synchronized copy +// of the auth state for immediate access by the Authenticator actor. +// Data races can occur when accessing the store directly within the actor. class AuthenticatorSubscription: NSObject { private static let inst: AuthenticatorSubscription = AuthenticatorSubscription() internal static var currentAuthState: AuthState? = Context.currentContext.store.state.auth + private var cancellable: AnyCancellable? - private override init() {} - - /// This checks the incoming action to determine whether it contains an AuthState payload and pushes that - /// to the Authenticator if present. This prevents race conditions between the internal Rownd state and any - /// external subscribers. The Authenticator MUST always reflect the correct state in order to prevent race conditions. - internal static func createAuthenticatorMiddleware() -> Middleware { - return { _, _ in - return { next in - return { action in - var authState: AuthState? - - switch action { - case let action as SetAuthState: - authState = action.payload - case let action as InitializeRowndState: - authState = action.payload.auth - default: - break - } + private override init() { + super.init() + startObserving() + } - guard let authState = authState else { - return next(action) - } - AuthenticatorSubscription.currentAuthState = authState - next(action) - } + /// Start observing auth state changes from the store. + private func startObserving() { + cancellable = Context.currentContext.store.publisher(for: \.auth) + .sink { [weak self] authState in + AuthenticatorSubscription.currentAuthState = authState } - } + } + + /// Update the current auth state directly (for immediate access before store update propagates). + internal static func updateAuthState(_ authState: AuthState) { + currentAuthState = authState } } @@ -190,20 +178,17 @@ actor Authenticator: AuthenticatorProtocol { log.debug("Successfully refreshed auth tokens.") // Store the new token response here for immediate use outside of the state lifecycle - AuthenticatorSubscription.currentAuthState = newAuthState - - // Update the auth state - this really should be abstracted out elsewhere - DispatchQueue.main.async { - Context.currentContext.store.dispatch( - SetAuthState( - payload: AuthState( - accessToken: newAuthState.accessToken, - refreshToken: newAuthState.refreshToken, - isVerifiedUser: Context.currentContext.store.state.auth - .isVerifiedUser, - hasPreviouslySignedIn: Context.currentContext.store.state.auth - .hasPreviouslySignedIn - ))) + AuthenticatorSubscription.updateAuthState(newAuthState) + + // Update the auth state + Task { + await Context.currentContext.store.mutate { state in + state.auth.accessToken = newAuthState.accessToken + state.auth.refreshToken = newAuthState.refreshToken + // Preserve existing values + state.auth.isVerifiedUser = state.auth.isVerifiedUser + state.auth.hasPreviouslySignedIn = state.auth.hasPreviouslySignedIn + } } return newAuthState @@ -231,7 +216,7 @@ actor Authenticator: AuthenticatorProtocol { default: break } - // Sign the user out b/c they need to get a new refresh token - this really should be abstracted out elsewhere + // Sign the user out b/c they need to get a new refresh token Rownd.signOut() throw @@ -247,27 +232,22 @@ actor Authenticator: AuthenticatorProtocol { private func waitForClockSync() async throws { try await withThrowingTaskGroup(of: Void.self) { group in - let continuationID = UUID() var didResume = false // Task 1: Wait for the clock sync group.addTask { @MainActor [weak self] in try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let subscriber = Context.currentContext.store.subscribe { $0.clockSyncState } var cancellable: AnyCancellable? - cancellable = subscriber.$current.sink { clockSyncState in - if clockSyncState != .waiting && !didResume { - didResume = true - continuation.resume() - // Defer cleanup to next runloop to avoid mutating subscriber set during notification - DispatchQueue.main.async { + cancellable = Context.currentContext.store.publisher(for: \.clockSyncState) + .sink { clockSyncState in + if clockSyncState != .waiting && !didResume { + didResume = true + continuation.resume() cancellable?.cancel() - subscriber.unsubscribe() } } - } Task { [weak self] in if let cancellable = cancellable { diff --git a/Sources/Rownd/framework/RowndEvent.swift b/Sources/Rownd/framework/RowndEvent.swift index b1c83ba..1f0099f 100644 --- a/Sources/Rownd/framework/RowndEvent.swift +++ b/Sources/Rownd/framework/RowndEvent.swift @@ -1,13 +1,13 @@ // -// File.swift -// +// RowndEvent.swift +// // // Created by Matt Hamann on 3/20/24. // -import Foundation import AnyCodable import Combine +import Foundation public enum RowndEventType: String, Codable { case signInStarted = "sign_in_started" @@ -32,17 +32,20 @@ public protocol RowndEventHandlerDelegate: AnyObject { class RowndEventEmitter { static private var cancellables = Set() + static func emit(_ event: RowndEvent) { if event.event == .signInCompleted { - let subscription = Context.currentContext.store.subscribe { $0.auth.isAccessTokenValid } - subscription.$current.sink { isAccessTokenValid in - if isAccessTokenValid { - subscription.unsubscribe() + // Wait for access token to be valid before emitting sign-in completed + Context.currentContext.store.publisher(for: \.auth.isAccessTokenValid) + .filter { $0 } + .first() + .receive(on: DispatchQueue.main) + .sink { _ in Context.currentContext.eventListeners.forEach { listener in listener.handleRowndEvent(event) } } - }.store(in: &Self.cancellables) + .store(in: &Self.cancellables) } else { Context.currentContext.eventListeners.forEach { listener in listener.handleRowndEvent(event) diff --git a/Sources/Rownd/framework/SmartLinks.swift b/Sources/Rownd/framework/SmartLinks.swift index af45e6c..444d2e3 100644 --- a/Sources/Rownd/framework/SmartLinks.swift +++ b/Sources/Rownd/framework/SmartLinks.swift @@ -112,30 +112,29 @@ class SmartLinks { ] )).value - Task { @MainActor in - if let accessToken = authResp.accessToken, let refreshToken = authResp.refreshToken { - Context.currentContext.store.dispatch(SetAuthState(payload: AuthState( - accessToken: accessToken, - refreshToken: refreshToken - ))) - - Context.currentContext.store.dispatch(UserData.fetch()) + if let accessToken = authResp.accessToken, let refreshToken = authResp.refreshToken { + await Context.currentContext.store.setAuth(AuthState( + accessToken: accessToken, + refreshToken: refreshToken + )) - Context.currentContext.store.dispatch(PasskeyData.fetchPasskeyRegistration()) + await UserData.fetch() + await PasskeyData.fetchPasskeyRegistration() + Task { @MainActor in if Rownd.isDisplayingHub() { Rownd.requestSignIn(jsFnOptions: RowndSignInJsOptions( loginStep: .success )) } } + } - guard let strRedirectUrl = authResp.redirectUrl, let redirectUrl = URL(string: strRedirectUrl) else { - return - } - - Rownd.config.deepLinkHandler?.handle(linkUrl: redirectUrl) + guard let strRedirectUrl = authResp.redirectUrl, let redirectUrl = URL(string: strRedirectUrl) else { + return } + + Rownd.config.deepLinkHandler?.handle(linkUrl: redirectUrl) } catch { Task { @MainActor in if Rownd.isDisplayingHub() { diff --git a/Sources/Rownd/framework/TimeManager.swift b/Sources/Rownd/framework/TimeManager.swift index 27a133e..0785e80 100644 --- a/Sources/Rownd/framework/TimeManager.swift +++ b/Sources/Rownd/framework/TimeManager.swift @@ -41,10 +41,8 @@ class NetworkTimeManager { Task { await fetchWorldTime() - Task { @MainActor in - if Context.currentContext.store.state.clockSyncState != .synced { - Context.currentContext.store.dispatch(SetClockSync(clockSyncState: .synced)) - } + Task { + await Context.currentContext.store.setClockSync(.synced) } } @@ -52,7 +50,9 @@ class NetworkTimeManager { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if Context.currentContext.store.state.clockSyncState == .waiting { self.log.warning("TimeManager clock not synced after \(ntpStart.distance(to: Date())) seconds.") - Context.currentContext.store.dispatch(SetClockSync(clockSyncState: .unknown)) + Task { + await Context.currentContext.store.setClockSync(.unknown) + } } } } diff --git a/Tests/RowndTests/AuthTests.swift b/Tests/RowndTests/AuthTests.swift index c368380..4d813d6 100644 --- a/Tests/RowndTests/AuthTests.swift +++ b/Tests/RowndTests/AuthTests.swift @@ -5,14 +5,13 @@ // Created by Matt Hamann on 9/21/22. // -import Foundation -import Mocker +import Combine import CryptoKit import Factory +import Foundation import Get -import ReSwiftThunk -import Combine import JWTDecode +import Mocker import Testing @testable import Rownd @@ -26,27 +25,23 @@ import Testing } } let store = Context.currentContext.store - await MainActor.run { - store.dispatch(SetClockSync(clockSyncState: .synced)) - } + await store.setClockSync(.synced) Mocker.removeAll() } - + @Test func testRefreshToken() async throws { let store = Context.currentContext.store - Task { @MainActor in - store.dispatch(SetAuthState(payload: AuthState( - accessToken: generateJwt(expires: NSDate().timeIntervalSince1970), // this will be expired - refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" - ))) - } + await store.setAuth(AuthState( + accessToken: generateJwt(expires: NSDate().timeIntervalSince1970), // this will be expired + refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" + )) let responseData = AuthState( accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 1000).timeIntervalSince1970), refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) ) - + var mock = Mock( url: URL(string: "https://api.rownd.io/hub/auth/token")!, ignoreQuery: true, @@ -56,11 +51,11 @@ import Testing .post : try JSONEncoder().encode(responseData) ] ) - + mock.onRequestHandler = OnRequestHandler(httpBodyType: [String:String].self) { request, postBodyArguments in print("Refresh called") } - + mock.register() let authState = try? await Context.currentContext.authenticator.refreshToken() @@ -69,23 +64,21 @@ import Testing #expect(authState?.accessToken != nil, "Access token should be present") #expect(authState?.accessToken == responseData.accessToken, "Access token should be updated") } - + @Test func testMultipleAuthenticatedReqeustsWithExpiredAccessToken() async throws { let store = Context.currentContext.store - Task { @MainActor in - store.dispatch(SetAuthState(payload: AuthState( - accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970), // this will be expired - refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" - ))) - } + await store.setAuth(AuthState( + accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970), // this will be expired + refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" + )) let responseData = AuthState( accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 1000).timeIntervalSince1970), refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) ) print("Response data will be: \(String(describing: responseData))") - + var numTimesRefreshCalled = 0 var mock = Mock( url: URL(string: "https://api.rownd.io/hub/auth/token")!, @@ -96,14 +89,14 @@ import Testing .post : try JSONEncoder().encode(responseData) ] ) - + mock.onRequestHandler = OnRequestHandler(httpBodyType: [String:String].self) { request, postBodyArguments in numTimesRefreshCalled += 1 print("Refresh called: \(numTimesRefreshCalled) times") } - + mock.delay = DispatchTimeInterval.seconds(2) - + mock.register() async let task1 = Rownd.getAccessToken() @@ -120,23 +113,21 @@ import Testing #expect(numTimesRefreshCalled == 1) } - + @Test func testRefreshTokenRetryWithHttpErrors() async throws { let store = Context.currentContext.store - Task { @MainActor in - store.dispatch(SetAuthState(payload: AuthState( - accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970), // this will be expired - refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" - ))) - } + await store.setAuth(AuthState( + accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970), // this will be expired + refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" + )) let responseData = AuthState( accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 1000).timeIntervalSince1970), refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) ) print("Response data will be: \(String(describing: responseData))") - + var numTimesRefreshCalled = 0 var mock = Mock( url: URL(string: "https://api.rownd.io/hub/auth/token")!, @@ -147,7 +138,7 @@ import Testing .post : try JSONEncoder().encode(["error": "Something went wrong"]) ] ) - + mock.onRequestHandler = OnRequestHandler(httpBodyType: [String:String].self) { request, postBodyArguments in // After a couple of errors, make the mock return normal status if numTimesRefreshCalled == 2 { @@ -164,34 +155,32 @@ import Testing Issue.record("Failed to register updated mock") } } - + numTimesRefreshCalled += 1 print("Refresh called: \(numTimesRefreshCalled) times") } - + mock.delay = DispatchTimeInterval.seconds(2) - + mock.register() let token1 = try await Rownd.getAccessToken() #expect(token1 == responseData.accessToken) } - + @Test func testRefreshTokenThrowsWhenOfflineShouldNotSignOut() async throws { let store = Context.currentContext.store - Task { @MainActor in - store.dispatch(SetAuthState(payload: AuthState( - accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970), // this will be expired - refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" - ))) - } + await store.setAuth(AuthState( + accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970), // this will be expired + refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" + )) let responseData = AuthState( accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 1000).timeIntervalSince1970), refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) ) - + Mock( url: URL(string: "https://api.rownd.io/hub/auth/token")!, ignoreQuery: true, @@ -210,7 +199,7 @@ import Testing #expect(store.state.auth.isAuthenticated == true) } } - + @Test func testSignOutWhenRefreshTokenIsAlreadyConsumed() async throws { Mock( url: URL(string: "https://api.rownd.io/hub/auth/token")!, @@ -225,47 +214,50 @@ import Testing ]) ] ).register() - + let accessToken = generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970) // this will be expired let store = Context.currentContext.store - - let authSubscriber = TestFilteredSubscriber() - store.subscribe(authSubscriber) { - $0.select { $0.auth } - } - await MainActor.run { - store.dispatch(SetAuthState(payload: AuthState( - accessToken: accessToken, - refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" - ))) - } + var authStateValues: [AuthState] = [] + var cancellable: AnyCancellable? + + cancellable = store.publisher(for: \.auth) + .sink { auth in + authStateValues.append(auth) + } - await Task { @MainActor in - #expect((authSubscriber.receivedValue as? AuthState)?.isAuthenticated == true, "User should be authenticated initially") + await store.setAuth(AuthState( + accessToken: accessToken, + refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" + )) - let accessToken2 = try? await Rownd.getAccessToken() - #expect(accessToken2 == nil, "Returned token should be nil") + try await Task.sleep(nanoseconds: 100_000_000) // 100ms - #expect((authSubscriber.receivedValue as? AuthState)?.isAuthenticated == false, "User should no longer be authenticated") - }.value + #expect(authStateValues.last?.isAuthenticated == true, "User should be authenticated initially") + + let accessToken2 = try? await Rownd.getAccessToken() + #expect(accessToken2 == nil, "Returned token should be nil") + + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + + #expect(store.state.auth.isAuthenticated == false, "User should no longer be authenticated") + + cancellable?.cancel() } - + @Test func testRefreshTokenThrowsWhenHttpServerErrorsOccur() async throws { let store = Context.currentContext.store - Task { @MainActor in - store.dispatch(SetAuthState(payload: AuthState( - accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970), // this will be expired - refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" - ))) - } + await store.setAuth(AuthState( + accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -1000).timeIntervalSince1970), // this will be expired + refreshToken: "eyJhbGciOiJFZERTQSIsImtpZCI6InNpZy0xNjQ0OTM3MzYwIn0.eyJqdGkiOiJiNzY4NmUxNC0zYjk2LTQzMTItOWM3ZS1iODdmOTlmYTAxMzIiLCJhdWQiOlsiYXBwOjMzNzA4MDg0OTIyMTU1MDY3MSJdLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExNDg5NTEyMjc5NTQ1MjEyNzI3NiIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9hcHBfdXNlcl9pZCI6ImM5YTgxMDM5LTBjYmMtNDFkNy05YTlkLWVhOWI1YTE5Y2JmMCIsImh0dHBzOi8vYXV0aC5yb3duZC5pby9pc192ZXJpZmllZF91c2VyIjp0cnVlLCJpc3MiOiJodHRwczovL2FwaS5yb3duZC5pbyIsImlhdCI6MTY2NTk3MTk0MiwiaHR0cHM6Ly9hdXRoLnJvd25kLmlvL2p3dF90eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY2ODU2Mzk0Mn0.Yn35j83bfFNgNk26gTvd4a2a2NAGXp7eknvOaFAtd3lWCdvtw6gKRso6Uzd7uydy2MWJFRWC38AkV6lMMfnrDw" + )) let responseData = AuthState( accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 1000).timeIntervalSince1970), refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) ) - + Mock( url: URL(string: "https://api.rownd.io/hub/auth/token")!, ignoreQuery: true, @@ -285,6 +277,9 @@ import Testing } @Test func testAccessTokenValidWithMargin() async throws { + let store = Context.currentContext.store + await store.setClockSync(.synced) + let accessTokenNew = AuthState( accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 3600).timeIntervalSince1970), refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) @@ -315,12 +310,10 @@ import Testing @Test func alwaysThrowWhenAccessTokenCannotBeRetrieved() async throws { let store = Context.currentContext.store - await MainActor.run { - store.dispatch(SetAuthState(payload: AuthState( - accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -3600).timeIntervalSince1970), - refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) - ))) - } + await store.setAuth(AuthState( + accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) + )) let authenticator = TestAuthenticator() Context.currentContext.authenticator = authenticator @@ -346,12 +339,10 @@ import Testing @Test func onlyThrowWhenAccessTokenCannotBeRetrievedForNetworkReasons() async throws { let store = Context.currentContext.store - await MainActor.run { - store.dispatch(SetAuthState(payload: AuthState( - accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -3600).timeIntervalSince1970), - refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) - ))) - } + await store.setAuth(AuthState( + accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: -3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date.init().timeIntervalSince1970) + )) let authenticator = TestAuthenticator() Context.currentContext.authenticator = authenticator @@ -387,20 +378,20 @@ struct Payload: Encodable { internal func generateJwt(expires: TimeInterval) -> String { let secret = "your-256-bit-secret" let privateKey = SymmetricKey(data: Data(secret.utf8)) - + let headerJSONData = try! JSONEncoder().encode(Header()) let headerBase64String = headerJSONData.urlSafeBase64EncodedString() - + var payload = Payload() payload.exp = Int(expires) let payloadJSONData = try! JSONEncoder().encode(payload) let payloadBase64String = payloadJSONData.urlSafeBase64EncodedString() - + let toSign = Data((headerBase64String + "." + payloadBase64String).utf8) - + let signature = HMAC.authenticationCode(for: toSign, using: privateKey) let signatureBase64String = Data(signature).urlSafeBase64EncodedString() - + let token = [headerBase64String, payloadBase64String, signatureBase64String].joined(separator: ".") return token } diff --git a/Tests/RowndTests/AutomationsCoordinatorTests.swift b/Tests/RowndTests/AutomationsCoordinatorTests.swift index bc15ca5..313a56d 100644 --- a/Tests/RowndTests/AutomationsCoordinatorTests.swift +++ b/Tests/RowndTests/AutomationsCoordinatorTests.swift @@ -1,5 +1,4 @@ import Foundation -import ReSwift import Testing @testable import Rownd @@ -14,12 +13,12 @@ struct AutomationsCoordinatorTests { let coordinator = AutomationsCoordinator() let iterations = 50 - // Dispatch a flurry of actions while starting the coordinator - let dispatchTask = Task { @MainActor in + // Dispatch a flurry of state updates while starting the coordinator + let dispatchTask = Task { for i in 0..() - let authSubscriber = TestFilteredSubscriber() - - store.subscribe(rootSubscriber) { - $0.select { $0 } - } - - store.subscribe(authSubscriber) { - $0.select { $0.auth } - } - - Task { @MainActor in - await store.state.load(store) - store.dispatch(SetAuthState(payload: AuthState( - accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 3600).timeIntervalSince1970), - refreshToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 36000).timeIntervalSince1970) - ))) - store.dispatch(SetClockSync(clockSyncState: .synced)) - try await Task.sleep(nanoseconds: 20000) - XCTAssertTrue((((rootSubscriber.receivedValue as? RowndState)?.isInitialized) == true)) - XCTAssertTrue((((authSubscriber.receivedValue as? AuthState)?.isAccessTokenValid) == true)) - expectation.fulfill() - } - - await fulfillment(of: [expectation], timeout: 10.0) - } - -} -class TestFilteredSubscriber: StoreSubscriber { - var receivedValue: T! - var newStateCallCount = 0 + var rootStateValues: [RowndState] = [] + var authStateValues: [AuthState] = [] + var cancellables = Set() - func newState(state: T) { - receivedValue = state - newStateCallCount += 1 - } + store.publisher() + .sink { state in + rootStateValues.append(state) + } + .store(in: &cancellables) + + store.publisher(for: \.auth) + .sink { auth in + authStateValues.append(auth) + } + .store(in: &cancellables) + + await store.load() + await store.setAuth(AuthState( + accessToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date.init(timeIntervalSinceNow: 36000).timeIntervalSince1970) + )) + await store.setClockSync(.synced) + + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + XCTAssertTrue(rootStateValues.last?.isInitialized == true) + XCTAssertTrue(authStateValues.last?.isAccessTokenValid == true) + expectation.fulfill() + + await fulfillment(of: [expectation], timeout: 10.0) + } } diff --git a/Tests/RowndTests/SubscriberMutationTests.swift b/Tests/RowndTests/SubscriberMutationTests.swift index 186c479..c14f0d3 100644 --- a/Tests/RowndTests/SubscriberMutationTests.swift +++ b/Tests/RowndTests/SubscriberMutationTests.swift @@ -1,6 +1,5 @@ import Combine import Foundation -import ReSwift import Testing @testable import Rownd @@ -11,22 +10,22 @@ struct SubscriberMutationTests { let store = Context.currentContext.store // Ensure starting state - _ = await store.state.load(store) + await store.load() let iterations = 50 - // Flip clockSyncState quickly on main - let flipTask = Task { @MainActor in + // Flip clockSyncState quickly + let flipTask = Task { for i in 0..