diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index fb095065..787af0c1 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -723,7 +723,13 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.supabase.swift-examples"; PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -743,7 +749,13 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.supabase.swift-examples"; PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; diff --git a/Examples/Examples/Auth/AuthView.swift b/Examples/Examples/Auth/AuthView.swift index e6dae5a8..88830900 100644 --- a/Examples/Examples/Auth/AuthView.swift +++ b/Examples/Examples/Auth/AuthView.swift @@ -14,7 +14,9 @@ struct AuthView: View { case signInWithPhone case signInWithApple case signInWithOAuth - case signInWithOAuthUsingUIKit + #if canImport(UIKit) + case signInWithOAuthUsingUIKit + #endif case googleSignInSDKFlow case signInAnonymously @@ -25,7 +27,9 @@ struct AuthView: View { case .signInWithPhone: "Sign in with Phone" case .signInWithApple: "Sign in with Apple" case .signInWithOAuth: "Sign in with OAuth flow" - case .signInWithOAuthUsingUIKit: "Sign in with OAuth flow (UIKit)" + #if canImport(UIKit) + case .signInWithOAuthUsingUIKit: "Sign in with OAuth flow (UIKit)" + #endif case .googleSignInSDKFlow: "Google Sign in (GIDSignIn SDK Flow)" case .signInAnonymously: "Sign in Anonymously" } @@ -43,7 +47,9 @@ struct AuthView: View { options .navigationTitle(options.title) } + #if !os(macOS) .navigationBarTitleDisplayMode(.inline) + #endif } } } @@ -56,8 +62,10 @@ extension AuthView.Option: View { case .signInWithPhone: SignInWithPhone() case .signInWithApple: SignInWithApple() case .signInWithOAuth: SignInWithOAuth() - case .signInWithOAuthUsingUIKit: UIViewControllerWrapper(SignInWithOAuthViewController()) - .edgesIgnoringSafeArea(.all) + #if canImport(UIKit) + case .signInWithOAuthUsingUIKit: UIViewControllerWrapper(SignInWithOAuthViewController()) + .edgesIgnoringSafeArea(.all) + #endif case .googleSignInSDKFlow: GoogleSignInSDKFlow() case .signInAnonymously: SignInAnonymously() } diff --git a/Examples/Examples/Auth/AuthWithEmailAndPassword.swift b/Examples/Examples/Auth/AuthWithEmailAndPassword.swift index f0687906..ae86eafe 100644 --- a/Examples/Examples/Auth/AuthWithEmailAndPassword.swift +++ b/Examples/Examples/Auth/AuthWithEmailAndPassword.swift @@ -29,15 +29,19 @@ struct AuthWithEmailAndPassword: View { Form { Section { TextField("Email", text: $email) - .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocorrectionDisabled() + #if !os(macOS) + .keyboardType(.emailAddress) .textInputAutocapitalization(.never) + #endif SecureField("Password", text: $password) .textContentType(.password) .autocorrectionDisabled() + #if !os(macOS) .textInputAutocapitalization(.never) + #endif } Section { diff --git a/Examples/Examples/Auth/AuthWithMagicLink.swift b/Examples/Examples/Auth/AuthWithMagicLink.swift index 7cbb3707..1267e223 100644 --- a/Examples/Examples/Auth/AuthWithMagicLink.swift +++ b/Examples/Examples/Auth/AuthWithMagicLink.swift @@ -15,10 +15,12 @@ struct AuthWithMagicLink: View { Form { Section { TextField("Email", text: $email) - .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocorrectionDisabled() + #if !os(macOS) + .keyboardType(.emailAddress) .textInputAutocapitalization(.never) + #endif } Section { diff --git a/Examples/Examples/Auth/GoogleSignInSDKFlow.swift b/Examples/Examples/Auth/GoogleSignInSDKFlow.swift index f7fb7fc7..6f4732f7 100644 --- a/Examples/Examples/Auth/GoogleSignInSDKFlow.swift +++ b/Examples/Examples/Auth/GoogleSignInSDKFlow.swift @@ -19,7 +19,7 @@ struct GoogleSignInSDKFlow: View { func handleSignIn() { Task { do { - let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) + let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: root) guard let idToken = result.user.idToken?.tokenString else { debug("No 'idToken' returned by GIDSignIn call.") @@ -38,9 +38,15 @@ struct GoogleSignInSDKFlow: View { } } - var rootViewController: UIViewController { - UIApplication.shared.firstKeyWindow?.rootViewController ?? UIViewController() - } + #if canImport(UIKit) + var root: UIViewController { + UIApplication.shared.firstKeyWindow?.rootViewController ?? UIViewController() + } + #else + var root: NSWindow { + NSApplication.shared.keyWindow ?? NSWindow() + } + #endif } #Preview { diff --git a/Examples/Examples/Auth/SignInWithOAuth.swift b/Examples/Examples/Auth/SignInWithOAuth.swift index cc325a6f..295c73eb 100644 --- a/Examples/Examples/Auth/SignInWithOAuth.swift +++ b/Examples/Examples/Auth/SignInWithOAuth.swift @@ -45,78 +45,81 @@ struct SignInWithOAuth: View { } } -final class SignInWithOAuthViewController: UIViewController, UIPickerViewDataSource, - UIPickerViewDelegate -{ - let providers = Provider.allCases - var provider = Provider.allCases[0] +#if canImport(UIKit) + final class SignInWithOAuthViewController: UIViewController, UIPickerViewDataSource, + UIPickerViewDelegate + { + let providers = Provider.allCases + var provider = Provider.allCases[0] + + let providerPicker = UIPickerView() + let signInButton = UIButton(type: .system) + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + } - let providerPicker = UIPickerView() - let signInButton = UIButton(type: .system) + func setupViews() { + view.backgroundColor = .white + + providerPicker.dataSource = self + providerPicker.delegate = self + view.addSubview(providerPicker) + providerPicker.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + providerPicker.centerXAnchor.constraint(equalTo: view.centerXAnchor), + providerPicker.centerYAnchor.constraint(equalTo: view.centerYAnchor), + providerPicker.widthAnchor.constraint(equalToConstant: 200), + providerPicker.heightAnchor.constraint(equalToConstant: 100), + ]) + + signInButton.setTitle("Start Sign-in Flow", for: .normal) + signInButton.addTarget(self, action: #selector(signInButtonTapped), for: .touchUpInside) + view.addSubview(signInButton) + signInButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + signInButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + signInButton.topAnchor.constraint(equalTo: providerPicker.bottomAnchor, constant: 20), + ]) + } - override func viewDidLoad() { - super.viewDidLoad() - setupViews() - } + @objc func signInButtonTapped() { + Task { + do { + try await supabase.auth.signInWithOAuth( + provider: provider, + redirectTo: Constants.redirectToURL + ) + } catch { + debug("Failed to sign-in with OAuth flow: \(error)") + } + } + } - func setupViews() { - view.backgroundColor = .white - - providerPicker.dataSource = self - providerPicker.delegate = self - view.addSubview(providerPicker) - providerPicker.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - providerPicker.centerXAnchor.constraint(equalTo: view.centerXAnchor), - providerPicker.centerYAnchor.constraint(equalTo: view.centerYAnchor), - providerPicker.widthAnchor.constraint(equalToConstant: 200), - providerPicker.heightAnchor.constraint(equalToConstant: 100), - ]) - - signInButton.setTitle("Start Sign-in Flow", for: .normal) - signInButton.addTarget(self, action: #selector(signInButtonTapped), for: .touchUpInside) - view.addSubview(signInButton) - signInButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - signInButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - signInButton.topAnchor.constraint(equalTo: providerPicker.bottomAnchor, constant: 20), - ]) - } + func numberOfComponents(in _: UIPickerView) -> Int { + 1 + } - @objc func signInButtonTapped() { - Task { - do { - try await supabase.auth.signInWithOAuth( - provider: provider, - redirectTo: Constants.redirectToURL - ) - } catch { - debug("Failed to sign-in with OAuth flow: \(error)") - } + func pickerView(_: UIPickerView, numberOfRowsInComponent _: Int) -> Int { + providers.count } - } - func numberOfComponents(in _: UIPickerView) -> Int { - 1 - } + func pickerView(_: UIPickerView, titleForRow row: Int, forComponent _: Int) -> String? { + "\(providers[row])" + } - func pickerView(_: UIPickerView, numberOfRowsInComponent _: Int) -> Int { - providers.count + func pickerView(_: UIPickerView, didSelectRow row: Int, inComponent _: Int) { + provider = providers[row] + } } - func pickerView(_: UIPickerView, titleForRow row: Int, forComponent _: Int) -> String? { - "\(providers[row])" + #Preview("UIKit") { + SignInWithOAuthViewController() } - func pickerView(_: UIPickerView, didSelectRow row: Int, inComponent _: Int) { - provider = providers[row] - } -} +#endif #Preview("SwiftUI") { SignInWithOAuth() } - -#Preview("UIKit") { - SignInWithOAuthViewController() -} diff --git a/Examples/Examples/Auth/SignInWithPhone.swift b/Examples/Examples/Auth/SignInWithPhone.swift index 9c5a1f46..be2cbc5e 100644 --- a/Examples/Examples/Auth/SignInWithPhone.swift +++ b/Examples/Examples/Auth/SignInWithPhone.swift @@ -33,10 +33,12 @@ struct SignInWithPhone: View { Form { Section { TextField("Phone", text: $phone) - .keyboardType(.phonePad) .textContentType(.telephoneNumber) .autocorrectionDisabled() + #if !os(macOS) + .keyboardType(.phonePad) .textInputAutocapitalization(.never) + #endif } Section { @@ -62,10 +64,12 @@ struct SignInWithPhone: View { Form { Section { TextField("Code", text: $code) - .keyboardType(.numberPad) .textContentType(.oneTimeCode) .autocorrectionDisabled() + #if !os(macOS) + .keyboardType(.numberPad) .textInputAutocapitalization(.never) + #endif } Section { diff --git a/Examples/Examples/Examples.entitlements b/Examples/Examples/Examples.entitlements index 4210463c..5776a3a2 100644 --- a/Examples/Examples/Examples.entitlements +++ b/Examples/Examples/Examples.entitlements @@ -2,13 +2,13 @@ - com.apple.developer.applesignin - - Default - com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only + com.apple.security.network.client + + keychain-access-groups + diff --git a/Examples/Examples/ExamplesApp.swift b/Examples/Examples/ExamplesApp.swift index 2ee2b7b9..948a293a 100644 --- a/Examples/Examples/ExamplesApp.swift +++ b/Examples/Examples/ExamplesApp.swift @@ -9,45 +9,15 @@ import GoogleSignIn import Supabase import SwiftUI -class AppDelegate: UIResponder, UIApplicationDelegate { - func application( - _: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - if let url = launchOptions?[.url] as? URL { - supabase.handle(url) - } - return true - } - - func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - supabase.handle(url) - return true - } - - func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { - let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) - configuration.delegateClass = SceneDelegate.self - return configuration - } -} - -class SceneDelegate: UIResponder, UISceneDelegate { - func scene(_: UIScene, openURLContexts URLContexts: Set) { - guard let url = URLContexts.first?.url else { return } - - supabase.handle(url) - } -} - @main struct ExamplesApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - var body: some Scene { WindowGroup { RootView() .environment(AuthController()) + .onOpenURL { + supabase.handle($0) + } } } } diff --git a/Examples/Examples/Profile/ResetPasswordView.swift b/Examples/Examples/Profile/ResetPasswordView.swift index ef02e593..b0307335 100644 --- a/Examples/Examples/Profile/ResetPasswordView.swift +++ b/Examples/Examples/Profile/ResetPasswordView.swift @@ -21,8 +21,10 @@ struct ResetPasswordView: View { TextField("Enter your email", text: $email) .textFieldStyle(RoundedBorderTextFieldStyle()) + #if !os(macOS) .autocapitalization(.none) .keyboardType(.emailAddress) + #endif Button(action: resetPassword) { Text("Send Reset Link") diff --git a/Examples/Examples/Profile/UpdateProfileView.swift b/Examples/Examples/Profile/UpdateProfileView.swift index 11e5e408..608b4afa 100644 --- a/Examples/Examples/Profile/UpdateProfileView.swift +++ b/Examples/Examples/Profile/UpdateProfileView.swift @@ -35,14 +35,18 @@ struct UpdateProfileView: View { Section { TextField("Email", text: $email) .textContentType(.emailAddress) - .keyboardType(.emailAddress) .autocorrectionDisabled() + #if !os(macOS) + .keyboardType(.emailAddress) .textInputAutocapitalization(.never) + #endif TextField("Phone", text: $phone) .textContentType(.telephoneNumber) - .keyboardType(.phonePad) .autocorrectionDisabled() + #if !os(macOS) + .keyboardType(.phonePad) .textInputAutocapitalization(.never) + #endif SecureField("New password", text: $password) .textContentType(.newPassword) } diff --git a/Examples/Examples/UIApplicationExtensions.swift b/Examples/Examples/UIApplicationExtensions.swift index e39ae820..e3a26aa5 100644 --- a/Examples/Examples/UIApplicationExtensions.swift +++ b/Examples/Examples/UIApplicationExtensions.swift @@ -5,14 +5,16 @@ // Created by Guilherme Souza on 05/03/24. // -import UIKit +#if canImport(UIKit) + import UIKit -extension UIApplication { - var firstKeyWindow: UIWindow? { - UIApplication.shared - .connectedScenes - .compactMap { $0 as? UIWindowScene } - .filter { $0.activationState == .foregroundActive } - .first?.keyWindow + extension UIApplication { + var firstKeyWindow: UIWindow? { + UIApplication.shared + .connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .first?.keyWindow + } } -} +#endif diff --git a/Examples/Examples/UIViewControllerWrapper.swift b/Examples/Examples/UIViewControllerWrapper.swift index 7738a041..04c9508c 100644 --- a/Examples/Examples/UIViewControllerWrapper.swift +++ b/Examples/Examples/UIViewControllerWrapper.swift @@ -5,22 +5,24 @@ // Created by Guilherme Souza on 10/04/24. // -import SwiftUI +#if canImport(UIKit) + import SwiftUI -struct UIViewControllerWrapper: UIViewControllerRepresentable { - typealias UIViewControllerType = T + struct UIViewControllerWrapper: UIViewControllerRepresentable { + typealias UIViewControllerType = T - let viewController: T + let viewController: T - init(_ viewController: T) { - self.viewController = viewController - } + init(_ viewController: T) { + self.viewController = viewController + } - func makeUIViewController(context _: Context) -> T { - viewController - } + func makeUIViewController(context _: Context) -> T { + viewController + } - func updateUIViewController(_: T, context _: Context) { - // Update the view controller if needed + func updateUIViewController(_: T, context _: Context) { + // Update the view controller if needed + } } -} +#endif diff --git a/Package.swift b/Package.swift index b3db6a73..2c4ec4fe 100644 --- a/Package.swift +++ b/Package.swift @@ -68,7 +68,12 @@ let package = Package( ], resources: [.process("Resources")] ), - .target(name: "Functions", dependencies: ["Helpers"]), + .target( + name: "Functions", + dependencies: [ + "Helpers", + ] + ), .testTarget( name: "FunctionsTests", dependencies: [ diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 138d898a..854a7c4b 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -10,6 +10,10 @@ import Helpers import FoundationNetworking #endif +#if canImport(WatchKit) + import WatchKit +#endif + typealias AuthClientID = UUID public final class AuthClient: Sendable { @@ -37,14 +41,14 @@ public final class AuthClient: Sendable { /// /// The session returned by this property may be expired. Use ``session`` for a session that is guaranteed to be valid. public var currentSession: Session? { - try? sessionStorage.get() + sessionStorage.get() } /// Returns the current user, if any. /// /// The user returned by this property may be outdated. Use ``user(jwt:)`` method to get an up-to-date user instance. public var currentUser: User? { - try? sessionStorage.get()?.user + currentSession?.user } /// Namespace for accessing multi-factor authentication API. @@ -72,8 +76,76 @@ public final class AuthClient: Sendable { sessionStorage: .live(clientID: clientID), sessionManager: .live(clientID: clientID) ) + + observeAppLifecycleChanges() } + #if canImport(ObjectiveC) + private func observeAppLifecycleChanges() { + #if canImport(UIKit) + #if canImport(WatchKit) + if #available(watchOS 7.0, *) { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: WKExtension.applicationDidBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillResignActive), + name: WKExtension.applicationWillResignActiveNotification, + object: nil + ) + } + #else + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + #endif + #elseif canImport(AppKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillResignActive), + name: NSApplication.willResignActiveNotification, + object: nil + ) + #endif + } + + @objc + private func handleDidBecomeActive() { + if configuration.autoRefreshToken { + startAutoRefresh() + } + } + + @objc + private func handleWillResignActive() { + if configuration.autoRefreshToken { + stopAutoRefresh() + } + } + #else + private func observeAppLifecycleChanges() { + // no-op + } + #endif /// Listen for auth state changes. /// - Parameter listener: Block that executes when a new event is emitted. /// - Returns: A handle that can be used to manually unsubscribe. @@ -1171,6 +1243,18 @@ public final class AuthClient: Sendable { return try await sessionManager.refreshSession(refreshToken) } + /// Starts an auto-refresh process in the background. The session is checked every few seconds. Close to the time of expiration a process is started to refresh the session. If refreshing fails it will be retried for as long as necessary. + /// + /// If you set ``Configuration/autoRefreshToken`` you don't need to call this function, it will be called for you. + public func startAutoRefresh() { + Task { await sessionManager.startAutoRefresh() } + } + + /// Stops an active auto refresh process running in the background (if any). + public func stopAutoRefresh() { + Task { await sessionManager.stopAutoRefresh() } + } + private func emitInitialSession(forToken token: ObservationToken) async { let session = try? await session eventEmitter.emit(.initialSession, session: session, token: token) diff --git a/Sources/Auth/AuthClientConfiguration.swift b/Sources/Auth/AuthClientConfiguration.swift index 4b724a0a..49b8577f 100644 --- a/Sources/Auth/AuthClientConfiguration.swift +++ b/Sources/Auth/AuthClientConfiguration.swift @@ -20,17 +20,28 @@ extension AuthClient { /// Configuration struct represents the client configuration. public struct Configuration: Sendable { + /// The URL of the Auth server. public let url: URL + + /// Any additional headers to send to the Auth server. public var headers: [String: String] public let flowType: AuthFlowType + + /// Default URL to be used for redirect on the flows that requires it. public let redirectToURL: URL? /// Optional key name used for storing tokens in local storage. public var storageKey: String? + + /// Provider your own local storage implementation to use instead of the default one. public let localStorage: any AuthLocalStorage + + /// Custom SupabaseLogger implementation used to inspecting log messages from the Auth library. public let logger: (any SupabaseLogger)? public let encoder: JSONEncoder public let decoder: JSONDecoder + + /// A custom fetch implementation. public let fetch: FetchHandler /// Set to `true` if you want to automatically refresh the token before expiring. @@ -51,7 +62,7 @@ extension AuthClient { /// - fetch: The asynchronous fetch handler for network requests. /// - autoRefreshToken: Set to `true` if you want to automatically refresh the token before expiring. public init( - url: URL, + url: URL? = nil, headers: [String: String] = [:], flowType: AuthFlowType = Configuration.defaultFlowType, redirectToURL: URL? = nil, @@ -65,7 +76,7 @@ extension AuthClient { ) { let headers = headers.merging(Configuration.defaultHeaders) { l, _ in l } - self.url = url + self.url = url ?? defaultAuthURL self.headers = headers self.flowType = flowType self.redirectToURL = redirectToURL @@ -94,7 +105,7 @@ extension AuthClient { /// - fetch: The asynchronous fetch handler for network requests. /// - autoRefreshToken: Set to `true` if you want to automatically refresh the token before expiring. public convenience init( - url: URL, + url: URL? = nil, headers: [String: String] = [:], flowType: AuthFlowType = AuthClient.Configuration.defaultFlowType, redirectToURL: URL? = nil, diff --git a/Sources/Auth/AuthError.swift b/Sources/Auth/AuthError.swift index 8430f8fd..427d1eb8 100644 --- a/Sources/Auth/AuthError.swift +++ b/Sources/Auth/AuthError.swift @@ -1,4 +1,5 @@ import Foundation +import Helpers #if canImport(FoundationNetworking) import FoundationNetworking @@ -47,7 +48,8 @@ extension ErrorCode { public static let oauthProviderNotSupported = ErrorCode("oauth_provider_not_supported") public static let unexpectedAudience = ErrorCode("unexpected_audience") public static let singleIdentityNotDeletable = ErrorCode("single_identity_not_deletable") - public static let emailConflictIdentityNotDeletable = ErrorCode("email_conflict_identity_not_deletable") + public static let emailConflictIdentityNotDeletable = ErrorCode( + "email_conflict_identity_not_deletable") public static let identityAlreadyExists = ErrorCode("identity_already_exists") public static let emailProviderDisabled = ErrorCode("email_provider_disabled") public static let phoneProviderDisabled = ErrorCode("phone_provider_disabled") @@ -108,14 +110,16 @@ public enum AuthError: LocalizedError, Equatable { @available( *, deprecated, - message: "Error used to be thrown when no exp claim was found in JWT during setSession(accessToken:refreshToken:) method." + message: + "Error used to be thrown when no exp claim was found in JWT during setSession(accessToken:refreshToken:) method." ) case missingExpClaim @available( *, deprecated, - message: "Error used to be thrown when provided JWT wasn't valid during setSession(accessToken:refreshToken:) method." + message: + "Error used to be thrown when provided JWT wasn't valid during setSession(accessToken:refreshToken:) method." ) case malformedJWT @@ -155,14 +159,16 @@ public enum AuthError: LocalizedError, Equatable { @available( *, deprecated, - message: "This error is never thrown, if you depend on it, you can remove the logic as it never happens." + message: + "This error is never thrown, if you depend on it, you can remove the logic as it never happens." ) case missingURL @available( *, deprecated, - message: "Error used to be thrown on methods which required a valid redirect scheme, such as signInWithOAuth. This is now considered a programming error an a assertion is triggered in case redirect scheme isn't provided." + message: + "Error used to be thrown on methods which required a valid redirect scheme, such as signInWithOAuth. This is now considered a programming error an a assertion is triggered in case redirect scheme isn't provided." ) case invalidRedirectScheme @@ -182,7 +188,7 @@ public enum AuthError: LocalizedError, Equatable { errorCode: .unknown, underlyingData: (try? AuthClient.Configuration.jsonEncoder.encode(error)) ?? Data(), underlyingResponse: HTTPURLResponse( - url: URL(string: "http://localhost")!, + url: defaultAuthURL, statusCode: error.code ?? 500, httpVersion: nil, headerFields: nil @@ -249,9 +255,9 @@ public enum AuthError: LocalizedError, Equatable { switch self { case .sessionMissing: "Auth session missing." case let .weakPassword(message, _), - let .api(message, _, _, _), - let .pkceGrantCodeExchange(message, _, _), - let .implicitGrantRedirect(message): + let .api(message, _, _, _), + let .pkceGrantCodeExchange(message, _, _), + let .implicitGrantRedirect(message): message // Deprecated cases case .missingExpClaim: "Missing expiration claim in the access token." @@ -281,3 +287,13 @@ public enum AuthError: LocalizedError, Equatable { return lhs == rhs } } + +extension AuthError: RetryableError { + package var shouldRetry: Bool { + switch self { + case .api(_, _, _, let response): + defaultRetryableHTTPStatusCodes.contains(response.statusCode) + default: false + } + } +} diff --git a/Sources/Auth/Internal/APIClient.swift b/Sources/Auth/Internal/APIClient.swift index 6bfbeec4..fc2d8521 100644 --- a/Sources/Auth/Internal/APIClient.swift +++ b/Sources/Auth/Internal/APIClient.swift @@ -37,7 +37,7 @@ struct APIClient: Sendable { request.headers = HTTPFields(configuration.headers).merging(with: request.headers) if request.headers[.apiVersionHeaderName] == nil { - request.headers[.apiVersionHeaderName] = API_VERSIONS[._20240101]!.name.rawValue + request.headers[.apiVersionHeaderName] = apiVersions[._20240101]!.name.rawValue } let response = try await http.send(request) @@ -78,7 +78,7 @@ struct APIClient: Sendable { let responseAPIVersion = parseResponseAPIVersion(response) - let errorCode: ErrorCode? = if let responseAPIVersion, responseAPIVersion >= API_VERSIONS[._20240101]!.timestamp, let code = error.code { + let errorCode: ErrorCode? = if let responseAPIVersion, responseAPIVersion >= apiVersions[._20240101]!.timestamp, let code = error.code { ErrorCode(code) } else { error.errorCode diff --git a/Sources/Auth/Internal/CodeVerifierStorage.swift b/Sources/Auth/Internal/CodeVerifierStorage.swift index 1df1e2c2..4f0ebc8d 100644 --- a/Sources/Auth/Internal/CodeVerifierStorage.swift +++ b/Sources/Auth/Internal/CodeVerifierStorage.swift @@ -10,7 +10,7 @@ struct CodeVerifierStorage: Sendable { extension CodeVerifierStorage { static func live(clientID: AuthClientID) -> Self { var configuration: AuthClient.Configuration { Dependencies[clientID].configuration } - var key: String { "\(configuration.storageKey ?? STORAGE_KEY)-code-verifier" } + var key: String { "\(configuration.storageKey ?? defaultStorageKey)-code-verifier" } return Self( get: { diff --git a/Sources/Auth/Internal/Contants.swift b/Sources/Auth/Internal/Contants.swift index c0c6a805..e504a2ee 100644 --- a/Sources/Auth/Internal/Contants.swift +++ b/Sources/Auth/Internal/Contants.swift @@ -8,16 +8,19 @@ import Foundation import HTTPTypes -let EXPIRY_MARGIN: TimeInterval = 30 -let STORAGE_KEY = "supabase.auth.token" +let defaultAuthURL = URL(string: "http://localhost:9999")! +let defaultExpiryMargin: TimeInterval = 30 -let API_VERSION_HEADER_NAME = "X-Supabase-Api-Version" +let autoRefreshTickDuration: TimeInterval = 30 +let autoRefreshTickThreshold = 3 + +let defaultStorageKey = "supabase.auth.token" extension HTTPField.Name { - static let apiVersionHeaderName = HTTPField.Name(API_VERSION_HEADER_NAME)! + static let apiVersionHeaderName = HTTPField.Name("X-Supabase-Api-Version")! } -let API_VERSIONS: [APIVersion.Name: APIVersion] = [ +let apiVersions: [APIVersion.Name: APIVersion] = [ ._20240101: ._20240101, ] diff --git a/Sources/Auth/Internal/SessionManager.swift b/Sources/Auth/Internal/SessionManager.swift index 2f07f47c..80cfc0ae 100644 --- a/Sources/Auth/Internal/SessionManager.swift +++ b/Sources/Auth/Internal/SessionManager.swift @@ -4,9 +4,11 @@ import Helpers struct SessionManager: Sendable { var session: @Sendable () async throws -> Session var refreshSession: @Sendable (_ refreshToken: String) async throws -> Session - var update: @Sendable (_ session: Session) async -> Void var remove: @Sendable () async -> Void + + var startAutoRefresh: @Sendable () async -> Void + var stopAutoRefresh: @Sendable () async -> Void } extension SessionManager { @@ -16,7 +18,9 @@ extension SessionManager { session: { try await instance.session() }, refreshSession: { try await instance.refreshSession($0) }, update: { await instance.update($0) }, - remove: { await instance.remove() } + remove: { await instance.remove() }, + startAutoRefresh: { await instance.startAutoRefreshToken() }, + stopAutoRefresh: { await instance.stopAutoRefreshToken() } ) } } @@ -29,7 +33,7 @@ private actor LiveSessionManager { private var api: APIClient { Dependencies[clientID].api } private var inFlightRefreshTask: Task? - private var scheduledNextRefreshTask: Task? + private var startAutoRefreshTokenTask: Task? let clientID: AuthClientID @@ -39,56 +43,76 @@ private actor LiveSessionManager { func session() async throws -> Session { try await trace(using: logger) { - guard let currentSession = try sessionStorage.get() else { + guard let currentSession = sessionStorage.get() else { + logger?.debug("session missing") throw AuthError.sessionMissing } if !currentSession.isExpired { - await scheduleNextTokenRefresh(currentSession) - return currentSession } + logger?.debug("session expired") return try await refreshSession(currentSession.refreshToken) } } func refreshSession(_ refreshToken: String) async throws -> Session { try await SupabaseLoggerTaskLocal.$additionalContext.withValue( - merging: ["refreshID": .string(UUID().uuidString)] + merging: [ + "refresh_id": .string(UUID().uuidString), + "refresh_token": .string(refreshToken), + ] ) { try await trace(using: logger) { if let inFlightRefreshTask { - logger?.debug("refresh already in flight") + logger?.debug("Refresh already in flight") return try await inFlightRefreshTask.value } inFlightRefreshTask = Task { - logger?.debug("refresh task started") + logger?.debug("Refresh task started") defer { inFlightRefreshTask = nil - logger?.debug("refresh task ended") + logger?.debug("Refresh task ended") } - let session = try await api.execute( - HTTPRequest( - url: configuration.url.appendingPathComponent("token"), - method: .post, - query: [ - URLQueryItem(name: "grant_type", value: "refresh_token"), - ], - body: configuration.encoder.encode(UserCredentials(refreshToken: refreshToken)) + do { + let session = try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("token"), + method: .post, + query: [ + URLQueryItem(name: "grant_type", value: "refresh_token") + ], + body: configuration.encoder.encode( + UserCredentials(refreshToken: refreshToken) + ) + ) ) - ) - .decoded(as: Session.self, decoder: configuration.decoder) - - update(session) - eventEmitter.emit(.tokenRefreshed, session: session) - - await scheduleNextTokenRefresh(session) - - return session + .decoded(as: Session.self, decoder: configuration.decoder) + + update(session) + eventEmitter.emit(.tokenRefreshed, session: session) + + return session + } catch { + logger?.debug("Refresh token failed with error: \(error)") + + // DO NOT remove session in case it is an error that should be retried. + // i.e. server instability, connection issues, ... + // + // Need to do this check here, because not all RetryableError's should be retried. + // URLError conforms to RetryableError, but only a subset of URLError should be retried, + // the same is true for AuthError. + if let error = error as? any RetryableError, error.shouldRetry { + throw error + } else { + remove() + throw error + } + } } return try await inFlightRefreshTask!.value @@ -97,55 +121,46 @@ private actor LiveSessionManager { } func update(_ session: Session) { - do { - try sessionStorage.store(session) - } catch { - logger?.error("Failed to store session: \(error)") - } + sessionStorage.store(session) } func remove() { - do { - try sessionStorage.delete() - } catch { - logger?.error("Failed to remove session: \(error)") - } + sessionStorage.delete() } - private func scheduleNextTokenRefresh(_ refreshedSession: Session, caller: StaticString = #function) async { - await SupabaseLoggerTaskLocal.$additionalContext.withValue( - merging: ["caller": .string("\(caller)")] - ) { - guard configuration.autoRefreshToken else { - logger?.debug("auto refresh token disabled") - return - } + func startAutoRefreshToken() { + logger?.debug("start auto refresh token") - guard scheduledNextRefreshTask == nil else { - logger?.debug("refresh task already scheduled") - return + startAutoRefreshTokenTask?.cancel() + startAutoRefreshTokenTask = Task { + while !Task.isCancelled { + await autoRefreshTokenTick() + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(autoRefreshTickDuration)) } + } + } - scheduledNextRefreshTask = Task { - await trace(using: logger) { - defer { scheduledNextRefreshTask = nil } - - let expiresAt = Date(timeIntervalSince1970: refreshedSession.expiresAt) - let expiresIn = expiresAt.timeIntervalSinceNow - - // if expiresIn < 0, it will refresh right away. - let timeToRefresh = max(expiresIn * 0.9, 0) + func stopAutoRefreshToken() { + logger?.debug("stop auto refresh token") + startAutoRefreshTokenTask?.cancel() + startAutoRefreshTokenTask = nil + } - logger?.debug("scheduled next token refresh in: \(timeToRefresh)s") + private func autoRefreshTokenTick() async { + await trace(using: logger) { + let now = Date().timeIntervalSince1970 - try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(timeToRefresh)) + guard let session = sessionStorage.get() else { + return + } - if Task.isCancelled { - return - } + let expiresInTicks = Int((session.expiresAt - now) / autoRefreshTickDuration) + logger?.debug( + "access token expires in \(expiresInTicks) ticks, a tick lasts \(autoRefreshTickDuration)s, refresh threshold is \(autoRefreshTickThreshold) ticks" + ) - _ = try? await refreshSession(refreshedSession.refreshToken) - } + if expiresInTicks <= autoRefreshTickThreshold { + _ = try? await refreshSession(session.refreshToken) } } } diff --git a/Sources/Auth/Internal/SessionStorage.swift b/Sources/Auth/Internal/SessionStorage.swift index 79bde470..a75338e2 100644 --- a/Sources/Auth/Internal/SessionStorage.swift +++ b/Sources/Auth/Internal/SessionStorage.swift @@ -9,9 +9,9 @@ import Foundation import Helpers struct SessionStorage { - var get: @Sendable () throws -> Session? - var store: @Sendable (_ session: Session) throws -> Void - var delete: @Sendable () throws -> Void + var get: @Sendable () -> Session? + var store: @Sendable (_ session: Session) -> Void + var delete: @Sendable () -> Void } extension SessionStorage { @@ -19,7 +19,7 @@ extension SessionStorage { /// /// It uses value from ``AuthClient/Configuration/storageKey`` or default to `supabase.auth.token` if not provided. static func key(_ clientID: AuthClientID) -> String { - Dependencies[clientID].configuration.storageKey ?? STORAGE_KEY + Dependencies[clientID].configuration.storageKey ?? defaultStorageKey } static func live(clientID: AuthClientID) -> SessionStorage { @@ -50,19 +50,32 @@ extension SessionStorage { } } - let storedData = try storage.retrieve(key: key) - return try storedData.flatMap { - try AuthClient.Configuration.jsonDecoder.decode(Session.self, from: $0) + do { + let storedData = try storage.retrieve(key: key) + return try storedData.flatMap { + try AuthClient.Configuration.jsonDecoder.decode(Session.self, from: $0) + } + } catch { + logger?.error("Failed to retrieve session: \(error.localizedDescription)") + return nil } }, store: { session in - try storage.store( - key: key, - value: AuthClient.Configuration.jsonEncoder.encode(session) - ) + do { + try storage.store( + key: key, + value: AuthClient.Configuration.jsonEncoder.encode(session) + ) + } catch { + logger?.error("Failed to store session: \(error.localizedDescription)") + } }, delete: { - try storage.remove(key: key) + do { + try storage.remove(key: key) + } catch { + logger?.error("Failed to delete session: \(error.localizedDescription)") + } } ) } diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index d850b542..3a56ea18 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -114,7 +114,7 @@ public struct Session: Codable, Hashable, Sendable { /// The 30 second buffer is to account for latency issues. public var isExpired: Bool { let expiresAt = Date(timeIntervalSince1970: expiresAt) - return expiresAt.timeIntervalSinceNow < EXPIRY_MARGIN + return expiresAt.timeIntervalSinceNow < defaultExpiryMargin } } @@ -286,6 +286,7 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { } } +/// One of the providers supported by Auth. public enum Provider: String, Identifiable, Codable, CaseIterable, Sendable { case apple case azure @@ -478,6 +479,7 @@ public struct UserAttributes: Codable, Hashable, Sendable { public var nonce: String? /// An email change token. + @available(*, deprecated, message: "This is an old field, stop relying on it.") public var emailChangeToken: String? /// A custom data object to store the user's metadata. This maps to the `auth.users.user_metadata` /// column. The `data` should be a JSON object that includes user-specific info, such as their diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 169d24ca..e9be2f71 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -22,13 +22,12 @@ public final class FunctionsClient: Sendable { /// The Region to invoke the functions in. let region: String? - private let http: any HTTPClientType - struct MutableState { /// Headers to be included in the requests. var headers = HTTPFields() } + private let http: any HTTPClientType private let mutableState = LockIsolated(MutableState()) var headers: HTTPFields { diff --git a/Sources/Helpers/EventEmitter.swift b/Sources/Helpers/EventEmitter.swift index 3d189d9f..4dd48e6f 100644 --- a/Sources/Helpers/EventEmitter.swift +++ b/Sources/Helpers/EventEmitter.swift @@ -8,11 +8,16 @@ import ConcurrencyExtras import Foundation -public final class ObservationToken: Sendable, Hashable { - let _onCancel = LockIsolated((@Sendable () -> Void)?.none) +public final class ObservationToken: @unchecked Sendable, Hashable { + private let _isCancelled = LockIsolated(false) + package var onCancel: @Sendable () -> Void - package init(_ onCancel: (@Sendable () -> Void)? = nil) { - _onCancel.setValue(onCancel) + public var isCancelled: Bool { + _isCancelled.withValue { $0 } + } + + package init(onCancel: @escaping @Sendable () -> Void = {}) { + self.onCancel = onCancel } @available(*, deprecated, renamed: "cancel") @@ -21,13 +26,10 @@ public final class ObservationToken: Sendable, Hashable { } public func cancel() { - _onCancel.withValue { - if $0 == nil { - return - } - - $0?() - $0 = nil + _isCancelled.withValue { isCancelled in + guard !isCancelled else { return } + defer { isCancelled = true } + onCancel() } } @@ -36,7 +38,7 @@ public final class ObservationToken: Sendable, Hashable { } public static func == (lhs: ObservationToken, rhs: ObservationToken) -> Bool { - ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + lhs === rhs } public func hash(into hasher: inout Hasher) { @@ -45,6 +47,10 @@ public final class ObservationToken: Sendable, Hashable { } extension ObservationToken { + public func store(in collection: inout some RangeReplaceableCollection) { + collection.append(self) + } + public func store(in set: inout Set) { set.insert(self) } @@ -53,7 +59,7 @@ extension ObservationToken { package final class EventEmitter: Sendable { public typealias Listener = @Sendable (Event) -> Void - private let listeners = LockIsolated<[ObjectIdentifier: Listener]>([:]) + private let listeners = LockIsolated<[(key: ObjectIdentifier, listener: Listener)]>([]) private let _lastEvent: LockIsolated package var lastEvent: Event { _lastEvent.value } @@ -77,14 +83,14 @@ package final class EventEmitter: Sendable { let token = ObservationToken() let key = ObjectIdentifier(token) - token._onCancel.setValue { [weak self] in + token.onCancel = { [weak self] in self?.listeners.withValue { - $0[key] = nil + $0.removeAll { $0.key == key } } } listeners.withValue { - $0[key] = listener + $0.append((key, listener)) } return token @@ -95,9 +101,9 @@ package final class EventEmitter: Sendable { let listeners = listeners.value if let token { - listeners[ObjectIdentifier(token)]?(event) + listeners.first { $0.key == ObjectIdentifier(token) }?.listener(event) } else { - for listener in listeners.values { + for (_, listener) in listeners { listener(event) } } diff --git a/Sources/Helpers/HTTP/RetryRequestInterceptor.swift b/Sources/Helpers/HTTP/RetryRequestInterceptor.swift index bbee5c90..68178206 100644 --- a/Sources/Helpers/HTTP/RetryRequestInterceptor.swift +++ b/Sources/Helpers/HTTP/RetryRequestInterceptor.swift @@ -30,22 +30,6 @@ package actor RetryRequestInterceptor: HTTPClientInterceptor { .delete, .get, .head, .options, .put, .trace, ] - /// The default set of retryable HTTP status codes. - package static let defaultRetryableHTTPStatusCodes: Set = [ - 408, 500, 502, 503, 504, - ] - - /// The default set of retryable URL error codes. - package static let defaultRetryableURLErrorCodes: Set = [ - .backgroundSessionInUseByAnotherProcess, .backgroundSessionWasDisconnected, - .badServerResponse, .callIsActive, .cannotConnectToHost, .cannotFindHost, - .cannotLoadFromNetwork, .dataNotAllowed, .dnsLookupFailed, - .downloadDecodingFailedMidStream, .downloadDecodingFailedToComplete, - .internationalRoamingOff, .networkConnectionLost, .notConnectedToInternet, - .secureConnectionFailed, .serverCertificateHasBadDate, - .serverCertificateNotYetValid, .timedOut, - ] - /// The maximum number of retries. package let retryLimit: Int /// The base value for exponential backoff. @@ -73,8 +57,8 @@ package actor RetryRequestInterceptor: HTTPClientInterceptor { exponentialBackoffBase: UInt = RetryRequestInterceptor.defaultExponentialBackoffBase, exponentialBackoffScale: Double = RetryRequestInterceptor.defaultExponentialBackoffScale, retryableHTTPMethods: Set = RetryRequestInterceptor.defaultRetryableHTTPMethods, - retryableHTTPStatusCodes: Set = RetryRequestInterceptor.defaultRetryableHTTPStatusCodes, - retryableErrorCodes: Set = RetryRequestInterceptor.defaultRetryableURLErrorCodes + retryableHTTPStatusCodes: Set = defaultRetryableHTTPStatusCodes, + retryableErrorCodes: Set = defaultRetryableURLErrorCodes ) { precondition( exponentialBackoffBase >= 2, "The `exponentialBackoffBase` must be a minimum of 2." diff --git a/Sources/Helpers/RetryableError.swift b/Sources/Helpers/RetryableError.swift new file mode 100644 index 00000000..cf053d2d --- /dev/null +++ b/Sources/Helpers/RetryableError.swift @@ -0,0 +1,35 @@ +// +// RetryableError.swift +// Supabase +// +// Created by Guilherme Souza on 15/10/24. +// +import Foundation + +/// An error type that can be retried. +package protocol RetryableError: Error { + /// Whether this error instance should be retried or not. + var shouldRetry: Bool { get } +} + +extension URLError: RetryableError { + package var shouldRetry: Bool { + defaultRetryableURLErrorCodes.contains(code) + } +} + +/// The default set of retryable URL error codes. +package let defaultRetryableURLErrorCodes: Set = [ + .backgroundSessionInUseByAnotherProcess, .backgroundSessionWasDisconnected, + .badServerResponse, .callIsActive, .cannotConnectToHost, .cannotFindHost, + .cannotLoadFromNetwork, .dataNotAllowed, .dnsLookupFailed, + .downloadDecodingFailedMidStream, .downloadDecodingFailedToComplete, + .internationalRoamingOff, .networkConnectionLost, .notConnectedToInternet, + .secureConnectionFailed, .serverCertificateHasBadDate, + .serverCertificateNotYetValid, .timedOut, +] + +/// The default set of retryable HTTP status codes. +package let defaultRetryableHTTPStatusCodes: Set = [ + 408, 500, 502, 503, 504, +] diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index 52f217aa..21ad5848 100644 --- a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -90,6 +90,15 @@ "version" : "1.3.3" } }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", + "version" : "1.3.0" + } + }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index bd5f2ef8..715fcab9 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -53,7 +53,7 @@ final class AuthClientTests: XCTestCase { func testOnAuthStateChanges() async throws { let session = Session.validSession let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(session) + Dependencies[sut.clientID].sessionStorage.store(session) let events = LockIsolated([AuthChangeEvent]()) @@ -71,7 +71,7 @@ final class AuthClientTests: XCTestCase { func testAuthStateChanges() async throws { let session = Session.validSession let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(session) + Dependencies[sut.clientID].sessionStorage.store(session) let stateChange = await sut.authStateChanges.first { _ in true } expectNoDifference(stateChange?.event, .initialSession) @@ -83,7 +83,7 @@ final class AuthClientTests: XCTestCase { .stub() } - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) let eventsTask = Task { await sut.authStateChanges.prefix(2).collect() @@ -112,11 +112,11 @@ final class AuthClientTests: XCTestCase { .stub() } - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) try await sut.signOut(scope: .others) - let sessionRemoved = try Dependencies[sut.clientID].sessionStorage.get() == nil + let sessionRemoved = Dependencies[sut.clientID].sessionStorage.get() == nil XCTAssertFalse(sessionRemoved) } @@ -131,7 +131,7 @@ final class AuthClientTests: XCTestCase { } let validSession = Session.validSession - try Dependencies[sut.clientID].sessionStorage.store(validSession) + Dependencies[sut.clientID].sessionStorage.store(validSession) let eventsTask = Task { await sut.authStateChanges.prefix(2).collect() @@ -147,7 +147,7 @@ final class AuthClientTests: XCTestCase { expectNoDifference(events, [.initialSession, .signedOut]) expectNoDifference(sessions, [.validSession, nil]) - let sessionRemoved = try Dependencies[sut.clientID].sessionStorage.get() == nil + let sessionRemoved = Dependencies[sut.clientID].sessionStorage.get() == nil XCTAssertTrue(sessionRemoved) } @@ -162,7 +162,7 @@ final class AuthClientTests: XCTestCase { } let validSession = Session.validSession - try Dependencies[sut.clientID].sessionStorage.store(validSession) + Dependencies[sut.clientID].sessionStorage.store(validSession) let eventsTask = Task { await sut.authStateChanges.prefix(2).collect() @@ -178,7 +178,7 @@ final class AuthClientTests: XCTestCase { expectNoDifference(events, [.initialSession, .signedOut]) expectNoDifference(sessions, [validSession, nil]) - let sessionRemoved = try Dependencies[sut.clientID].sessionStorage.get() == nil + let sessionRemoved = Dependencies[sut.clientID].sessionStorage.get() == nil XCTAssertTrue(sessionRemoved) } @@ -193,7 +193,7 @@ final class AuthClientTests: XCTestCase { } let validSession = Session.validSession - try Dependencies[sut.clientID].sessionStorage.store(validSession) + Dependencies[sut.clientID].sessionStorage.store(validSession) let eventsTask = Task { await sut.authStateChanges.prefix(2).collect() @@ -209,7 +209,7 @@ final class AuthClientTests: XCTestCase { expectNoDifference(events, [.initialSession, .signedOut]) expectNoDifference(sessions, [validSession, nil]) - let sessionRemoved = try Dependencies[sut.clientID].sessionStorage.get() == nil + let sessionRemoved = Dependencies[sut.clientID].sessionStorage.get() == nil XCTAssertTrue(sessionRemoved) } @@ -269,7 +269,7 @@ final class AuthClientTests: XCTestCase { ) } - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) let response = try await sut.getLinkIdentityURL(provider: .github) @@ -294,7 +294,7 @@ final class AuthClientTests: XCTestCase { ) } - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) let receivedURL = LockIsolated(nil) Dependencies[sut.clientID].urlOpener.open = { url in diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index c109c5de..a2b36c79 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -192,7 +192,7 @@ final class RequestsTests: XCTestCase { func testSetSessionWithAFutureExpirationDate() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) let accessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo0ODUyMTYzNTkzLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6ImhpQGJpbmFyeXNjcmFwaW5nLmNvIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6e30sInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.UiEhoahP9GNrBKw_OHBWyqYudtoIlZGkrjs7Qa8hU7I" @@ -215,7 +215,7 @@ final class RequestsTests: XCTestCase { func testSignOut() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { try await sut.signOut() @@ -224,7 +224,7 @@ final class RequestsTests: XCTestCase { func testSignOutWithLocalScope() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { try await sut.signOut(scope: .local) @@ -234,7 +234,7 @@ final class RequestsTests: XCTestCase { func testSignOutWithOthersScope() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { try await sut.signOut(scope: .others) @@ -282,7 +282,7 @@ final class RequestsTests: XCTestCase { func testUpdateUser() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { try await sut.update( @@ -346,7 +346,7 @@ final class RequestsTests: XCTestCase { func testReauthenticate() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { try await sut.reauthenticate() @@ -356,7 +356,7 @@ final class RequestsTests: XCTestCase { func testUnlinkIdentity() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { try await sut.unlinkIdentity( @@ -412,7 +412,7 @@ final class RequestsTests: XCTestCase { func testGetLinkIdentityURL() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { _ = try await sut.getLinkIdentityURL( @@ -427,7 +427,7 @@ final class RequestsTests: XCTestCase { func testMFAEnrollLegacy() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { _ = try await sut.mfa.enroll(params: MFAEnrollParams(issuer: "supabase.com", friendlyName: "test")) @@ -437,7 +437,7 @@ final class RequestsTests: XCTestCase { func testMFAEnrollTotp() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { _ = try await sut.mfa.enroll(params: .totp(issuer: "supabase.com", friendlyName: "test")) @@ -447,7 +447,7 @@ final class RequestsTests: XCTestCase { func testMFAEnrollPhone() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { _ = try await sut.mfa.enroll(params: .phone(friendlyName: "test", phone: "+1 202-918-2132")) @@ -457,7 +457,7 @@ final class RequestsTests: XCTestCase { func testMFAChallenge() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { _ = try await sut.mfa.challenge(params: .init(factorId: "123")) @@ -467,7 +467,7 @@ final class RequestsTests: XCTestCase { func testMFAChallengePhone() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { _ = try await sut.mfa.challenge(params: .init(factorId: "123", channel: .whatsapp)) @@ -477,7 +477,7 @@ final class RequestsTests: XCTestCase { func testMFAVerify() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { _ = try await sut.mfa.verify(params: .init(factorId: "123", challengeId: "123", code: "123456")) @@ -487,7 +487,7 @@ final class RequestsTests: XCTestCase { func testMFAUnenroll() async throws { let sut = makeSUT() - try Dependencies[sut.clientID].sessionStorage.store(.validSession) + Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { _ = try await sut.mfa.unenroll(params: .init(factorId: "123")) diff --git a/Tests/AuthTests/SessionManagerTests.swift b/Tests/AuthTests/SessionManagerTests.swift index ff5ceba1..3d866055 100644 --- a/Tests/AuthTests/SessionManagerTests.swift +++ b/Tests/AuthTests/SessionManagerTests.swift @@ -5,7 +5,6 @@ // Created by Guilherme Souza on 23/10/23. // -@testable import Auth import ConcurrencyExtras import CustomDump import Helpers @@ -14,6 +13,8 @@ import TestHelpers import XCTest import XCTestDynamicOverlay +@testable import Auth + final class SessionManagerTests: XCTestCase { var http: HTTPClientMock! @@ -42,6 +43,12 @@ final class SessionManagerTests: XCTestCase { ) } + override func invokeTest() { + withMainSerialExecutor { + super.invokeTest() + } + } + func testSession_shouldFailWithSessionNotFound() async { do { _ = try await sut.session() @@ -57,57 +64,53 @@ final class SessionManagerTests: XCTestCase { } func testSession_shouldReturnValidSession() async throws { - try await withMainSerialExecutor { - let session = Session.validSession - try Dependencies[clientID].sessionStorage.store(session) + let session = Session.validSession + Dependencies[clientID].sessionStorage.store(session) - let returnedSession = try await sut.session() - expectNoDifference(returnedSession, session) - } + let returnedSession = try await sut.session() + expectNoDifference(returnedSession, session) } func testSession_shouldRefreshSession_whenCurrentSessionExpired() async throws { - try await withMainSerialExecutor { - let currentSession = Session.expiredSession - try Dependencies[clientID].sessionStorage.store(currentSession) - - let validSession = Session.validSession - - let refreshSessionCallCount = LockIsolated(0) - - let (refreshSessionStream, refreshSessionContinuation) = AsyncStream.makeStream() - - await http.when( - { $0.url.path.contains("/token") }, - return: { _ in - refreshSessionCallCount.withValue { $0 += 1 } - let session = await refreshSessionStream.first(where: { _ in true })! - return .stub(session) - } - ) - - // Fire N tasks and call sut.session() - let tasks = (0 ..< 10).map { _ in - Task { [weak self] in - try await self?.sut.session() - } - } + let currentSession = Session.expiredSession + Dependencies[clientID].sessionStorage.store(currentSession) - await Task.yield() + let validSession = Session.validSession - refreshSessionContinuation.yield(validSession) - refreshSessionContinuation.finish() + let refreshSessionCallCount = LockIsolated(0) - // Await for all tasks to complete. - var result: [Result] = [] - for task in tasks { - let value = await task.result - result.append(value) + let (refreshSessionStream, refreshSessionContinuation) = AsyncStream.makeStream() + + await http.when( + { $0.url.path.contains("/token") }, + return: { _ in + refreshSessionCallCount.withValue { $0 += 1 } + let session = await refreshSessionStream.first(where: { _ in true })! + return .stub(session) + } + ) + + // Fire N tasks and call sut.session() + let tasks = (0..<10).map { _ in + Task { [weak self] in + try await self?.sut.session() } + } + + await Task.yield() - // Verify that refresher and storage was called only once. - XCTAssertEqual(refreshSessionCallCount.value, 1) - XCTAssertEqual(try result.map { try $0.get() }, (0 ..< 10).map { _ in validSession }) + refreshSessionContinuation.yield(validSession) + refreshSessionContinuation.finish() + + // Await for all tasks to complete. + var result: [Result] = [] + for task in tasks { + let value = await task.result + result.append(value) } + + // Verify that refresher and storage was called only once. + XCTAssertEqual(refreshSessionCallCount.value, 1) + XCTAssertEqual(try result.map { try $0.get() }, (0..<10).map { _ in validSession }) } } diff --git a/Tests/AuthTests/StoredSessionTests.swift b/Tests/AuthTests/StoredSessionTests.swift index e6b834e8..9e466ec2 100644 --- a/Tests/AuthTests/StoredSessionTests.swift +++ b/Tests/AuthTests/StoredSessionTests.swift @@ -24,7 +24,7 @@ final class StoredSessionTests: XCTestCase { let sut = Dependencies[clientID].sessionStorage - let _ = try sut.get() + XCTAssertNotNil(sut.get()) let session = Session( accessToken: "accesstoken", @@ -77,7 +77,8 @@ final class StoredSessionTests: XCTestCase { ) ) - try sut.store(session) + sut.store(session) + XCTAssertNotNil(sut.get()) } private final class DiskTestStorage: AuthLocalStorage { diff --git a/Tests/HelpersTests/EventEmitterTests.swift b/Tests/HelpersTests/EventEmitterTests.swift new file mode 100644 index 00000000..869b6090 --- /dev/null +++ b/Tests/HelpersTests/EventEmitterTests.swift @@ -0,0 +1,67 @@ +// +// EventEmitterTests.swift +// Supabase +// +// Created by Guilherme Souza on 15/10/24. +// + +import ConcurrencyExtras +import XCTest + +@testable import Helpers + +final class EventEmitterTests: XCTestCase { + + func testBasics() { + let sut = EventEmitter(initialEvent: "0") + XCTAssertTrue(sut.emitsLastEventWhenAttaching) + + XCTAssertEqual(sut.lastEvent, "0") + + let receivedEvents = LockIsolated<[String]>([]) + + let tokenA = sut.attach { value in + receivedEvents.withValue { $0.append("a" + value) } + } + + let tokenB = sut.attach { value in + receivedEvents.withValue { $0.append("b" + value) } + } + + sut.emit("1") + sut.emit("2") + sut.emit("3") + sut.emit("4") + + sut.emit("5", to: tokenA) + sut.emit("6", to: tokenB) + + tokenA.cancel() + + sut.emit("7") + sut.emit("8") + + XCTAssertEqual(sut.lastEvent, "8") + + XCTAssertEqual( + receivedEvents.value, + ["a0", "b0", "a1", "b1", "a2", "b2", "a3", "b3", "a4", "b4", "a5", "b6", "b7", "b8"] + ) + } + + func test_dontEmitLastEventWhenAttaching() { + let sut = EventEmitter(initialEvent: "0", emitsLastEventWhenAttaching: false) + XCTAssertFalse(sut.emitsLastEventWhenAttaching) + + let receivedEvent = LockIsolated<[String]>([]) + let token = sut.attach { value in + receivedEvent.withValue { $0.append(value) } + } + + sut.emit("1") + + XCTAssertEqual(receivedEvent.value, ["1"]) + + token.cancel() + } +} diff --git a/Tests/HelpersTests/ObservationTokenTests.swift b/Tests/HelpersTests/ObservationTokenTests.swift index a93602ef..16eec655 100644 --- a/Tests/HelpersTests/ObservationTokenTests.swift +++ b/Tests/HelpersTests/ObservationTokenTests.swift @@ -15,7 +15,7 @@ final class ObservationTokenTests: XCTestCase { let handle = ObservationToken() let onRemoveCallCount = LockIsolated(0) - handle._onCancel.setValue { + handle.onCancel = { onRemoveCallCount.withValue { $0 += 1 } @@ -31,7 +31,7 @@ final class ObservationTokenTests: XCTestCase { var handle: ObservationToken? = ObservationToken() let onRemoveCallCount = LockIsolated(0) - handle?._onCancel.setValue { + handle?.onCancel = { onRemoveCallCount.withValue { $0 += 1 }