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
}