diff --git a/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/CrossmintAuthManager.swift b/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/CrossmintAuthManager.swift index c37c76b..3ed493f 100644 --- a/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/CrossmintAuthManager.swift +++ b/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/CrossmintAuthManager.swift @@ -5,9 +5,7 @@ // Created by Austin Feight on 11/24/25. // -import CrossmintAuth import CrossmintClient let crossmintApiKey = "ck_staging_YOUR_API_KEY" -// swiftlint:disable:next force_try -let crossmintAuthManager = try! CrossmintAuthManager(apiKey: crossmintApiKey) +@MainActor var authManager: CrossmintAuthManager { CrossmintSDK.shared.authManager } diff --git a/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/JWTSignInView.swift b/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/JWTSignInView.swift index 82bddfc..5463e09 100644 --- a/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/JWTSignInView.swift +++ b/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/JWTSignInView.swift @@ -77,7 +77,7 @@ struct JWTSignInView: View { } isSigningIn = true Task { - await crossmintAuthManager.setJWT(token) + await CrossmintSDK.shared.setJWT(token) let authStatus = AuthenticationStatus.authenticated(email: email, jwt: token, secret: "") withAnimation(AnimationConstants.easeInOut()) { authenticationStatus = authStatus diff --git a/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/OTPSignInView.swift b/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/OTPSignInView.swift index e7a1c7f..9d4bf19 100644 --- a/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/OTPSignInView.swift +++ b/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/OTPSignInView.swift @@ -74,7 +74,7 @@ struct OTPSignInView: View { isSigningIn = true Task { do { - try await crossmintAuthManager.sendEmailOtp(email: email) + try await authManager.sendEmailOtp(email: email) isSigningIn = false showOTPVerification = true } catch let authError as AuthManagerError { diff --git a/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/VerificationView.swift b/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/VerificationView.swift index dddcfde..5caa1d8 100644 --- a/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/VerificationView.swift +++ b/Examples/SmartWalletsDemo/SmartWalletsDemo/Login/VerificationView.swift @@ -2,8 +2,6 @@ import SwiftUI import CrossmintClient struct VerificationView: View { - private let sdk: CrossmintSDK = .shared - @Binding var authenticationStatus: AuthenticationStatus? @State private var verificationCode: String = "" @@ -12,10 +10,6 @@ struct VerificationView: View { @State private var alertMessage: String = "" @State private var opacity: Double = 0 - private var authManager: AuthManager { - sdk.authManager - } - let email: String var body: some View { @@ -82,7 +76,7 @@ struct VerificationView: View { isVerifying = true Task { do { - let status = try await crossmintAuthManager.confirmEmailOtp(email: email, code: verificationCode) + let status = try await authManager.confirmEmailOtp(email: email, code: verificationCode) isVerifying = false @@ -91,9 +85,8 @@ struct VerificationView: View { opacity = 0 } - DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.duration) { - authenticationStatus = authStatus - } + try? await Task.sleep(for: .seconds(AnimationConstants.duration)) + authenticationStatus = authStatus } } catch { isVerifying = false @@ -106,7 +99,7 @@ struct VerificationView: View { private func resendCode() { Task { do { - try await crossmintAuthManager.sendEmailOtp(email: email) + try await authManager.sendEmailOtp(email: email) showAlert(with: "A new verification code has been sent to your email.") } catch { showAlert(with: "Error sending new code: \(error.localizedDescription)") @@ -121,10 +114,9 @@ struct VerificationView: View { } Task { - _ = await crossmintAuthManager.reset() - DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.duration) { - authenticationStatus = .nonAuthenticated - } + _ = await authManager.reset() + try? await Task.sleep(for: .seconds(AnimationConstants.duration)) + authenticationStatus = .nonAuthenticated } } diff --git a/Examples/SmartWalletsDemo/SmartWalletsDemo/Playground/PlaygroundView.swift b/Examples/SmartWalletsDemo/SmartWalletsDemo/Playground/PlaygroundView.swift index 5add07b..7fd4562 100644 --- a/Examples/SmartWalletsDemo/SmartWalletsDemo/Playground/PlaygroundView.swift +++ b/Examples/SmartWalletsDemo/SmartWalletsDemo/Playground/PlaygroundView.swift @@ -79,8 +79,7 @@ struct PlaygroundView: View { } private func signOut() async { - try? await crossmintAuthManager.logout() - CrossmintSDK.shared.logout() + await CrossmintSDK.shared.logout() authenticationStatus = .nonAuthenticated } } diff --git a/Examples/SmartWalletsDemo/SmartWalletsDemo/SmartWalletsDemoApp.swift b/Examples/SmartWalletsDemo/SmartWalletsDemo/SmartWalletsDemoApp.swift index 115852b..1c6a4dd 100644 --- a/Examples/SmartWalletsDemo/SmartWalletsDemo/SmartWalletsDemoApp.swift +++ b/Examples/SmartWalletsDemo/SmartWalletsDemo/SmartWalletsDemoApp.swift @@ -3,12 +3,14 @@ import CrossmintClient @main struct SmartWalletsDemoApp: App { + init() { + CrossmintSDK.configure(apiKey: crossmintApiKey, logLevel: .info) + } + var body: some Scene { WindowGroup { SplashScreen() - .crossmintNonCustodialSigner( - CrossmintSDK.shared(apiKey: crossmintApiKey, authManager: crossmintAuthManager, logLevel: .info) - ) + .crossmintNonCustodialSigner() } } } diff --git a/Examples/SmartWalletsDemo/SmartWalletsDemo/SplashScreen.swift b/Examples/SmartWalletsDemo/SmartWalletsDemo/SplashScreen.swift index 6d7b696..95dfd62 100644 --- a/Examples/SmartWalletsDemo/SmartWalletsDemo/SplashScreen.swift +++ b/Examples/SmartWalletsDemo/SmartWalletsDemo/SplashScreen.swift @@ -38,9 +38,6 @@ struct SplashScreen: View { @State private var authenticationStatus: AuthenticationStatus? @State private var transitionOpacity: Double = 0 @State private var error: Error? - private var authManager: AuthManager { - CrossmintSDK.shared.authManager - } @ViewBuilder private var splashContent: some View { @@ -130,7 +127,7 @@ struct SplashScreen: View { guard authenticationStatus == nil else { return } isLoading = true do { - authenticationStatus = try await crossmintAuthManager.authenticationStatus + authenticationStatus = try await authManager.authenticationStatus } catch { if case .signInRequired = error { self.error = .invalidCredentialsStored diff --git a/Sources/CrossmintClient/ClientSDK.swift b/Sources/CrossmintClient/ClientSDK.swift index af48ffb..d7999cb 100644 --- a/Sources/CrossmintClient/ClientSDK.swift +++ b/Sources/CrossmintClient/ClientSDK.swift @@ -1,8 +1,9 @@ import CrossmintAuth +import CrossmintService import Wallet protocol ClientSDK { func crossmintWallets() -> CrossmintWallets - var authManager: AuthManager { get } - var isProductionEnvironment: Bool { get } + var authManager: CrossmintAuthManager { get } + var crossmintService: CrossmintService { get } } diff --git a/Sources/CrossmintClient/CrossmintClient.swift b/Sources/CrossmintClient/CrossmintClient.swift index dce388d..4da2f63 100644 --- a/Sources/CrossmintClient/CrossmintClient.swift +++ b/Sources/CrossmintClient/CrossmintClient.swift @@ -16,7 +16,7 @@ actor CrossmintClient { self.apiKey = apiKey } - static func sdk(key: String, authManager: AuthManager? = nil) throws -> ClientSDK { + static func sdk(key: String, authManager: CrossmintAuthManager? = nil) throws -> ClientSDK { lock.lock() defer { lock.unlock() } diff --git a/Sources/CrossmintClient/CrossmintClientSDK.swift b/Sources/CrossmintClient/CrossmintClientSDK.swift index 24cfa1a..bf46050 100644 --- a/Sources/CrossmintClient/CrossmintClientSDK.swift +++ b/Sources/CrossmintClient/CrossmintClientSDK.swift @@ -9,10 +9,10 @@ final class CrossmintClientSDK: ClientSDK, Sendable { private let apiKey: ApiKey private let secureStorage: SecureStorage private let secureWalletStorage: SecureWalletStorage - private let crossmintService: CrossmintService - let authManager: any CrossmintAuth.AuthManager + let crossmintService: CrossmintService + let authManager: CrossmintAuthManager - init(apiKey: ApiKey, authManager: AuthManager? = nil) { + init(apiKey: ApiKey, authManager: CrossmintAuthManager? = nil) { self.apiKey = apiKey guard let bundleId = Bundle.main.bundleIdentifier else { diff --git a/Sources/CrossmintClient/SwiftUI/CrossmintSDK.swift b/Sources/CrossmintClient/SwiftUI/CrossmintSDK.swift index d5f844b..6b546bf 100644 --- a/Sources/CrossmintClient/SwiftUI/CrossmintSDK.swift +++ b/Sources/CrossmintClient/SwiftUI/CrossmintSDK.swift @@ -12,37 +12,34 @@ import Web @MainActor final public class CrossmintSDK: ObservableObject { - private static var _shared: CrossmintSDK? + @MainActor private static var _shared: CrossmintSDK? - public static var shared: CrossmintSDK { - guard let shared = _shared else { - let newInstance = CrossmintSDK() - _shared = newInstance - return newInstance + @MainActor public static var shared: CrossmintSDK { + guard let instance = _shared else { + fatalError( + "CrossmintSDK is not configured. " + + "Call CrossmintSDK.configure(apiKey:) before accessing CrossmintSDK.shared." + ) } - return shared + return instance } - public static func shared( - apiKey: String, - authManager: AuthManager? = nil, - logLevel: LogLevel = .error - ) -> CrossmintSDK { - if let existing = _shared { - return existing + /// Configures the SDK with the given API key. Must be called before accessing `CrossmintSDK.shared`. + /// Subsequent calls are ignored — the SDK can only be configured once per process. + @MainActor public static func configure(apiKey: String, logLevel: LogLevel = .error) { + guard _shared == nil else { + Logger.sdk.warn("CrossmintSDK.configure() called after SDK is already configured — ignoring") + return } - Logger.level = logLevel - let newInstance = CrossmintSDK(apiKey: apiKey, authManager: authManager) - _shared = newInstance - return newInstance + _shared = CrossmintSDK(apiKey: apiKey) } private let sdk: ClientSDK public let crossmintWallets: CrossmintWallets - public let authManager: AuthManager - public let isProductionEnvironment: Bool + public let authManager: CrossmintAuthManager + public let crossmintService: CrossmintService let crossmintTEE: CrossmintTEE @@ -56,46 +53,52 @@ final public class CrossmintSDK: ObservableObject { crossmintTEE.cancelOTP() } - private convenience init() { - #if DEBUG - if let apiKey = ProcessInfo.processInfo.environment["CROSSMINT_API_KEY"] { - Logger.client.info("Using API key from the environment variable.") - self.init(apiKey: apiKey) - return - } - #endif - Logger.client.error("Crossmint SDK requires an API key") - fatalError( - "Crossmint SDK requires an API key. " + - "Please call CrossmintSDK.shared(apiKey:) before accessing CrossmintSDK.shared" - ) + public var isProductionEnvironment: Bool { + crossmintService.isProductionEnvironment } - private init(apiKey: String, authManager: AuthManager? = nil) { + /// Sets a JWT for authentication. Use this when authenticating with an externally obtained token + /// rather than through the built-in OTP flow. + /// + /// - Note: Unlike the TypeScript SDK's synchronous `setJwt`, this is `async` because it + /// updates actor-isolated state on `CrossmintAuthManager`. + public func setJWT(_ jwt: String) async { + await authManager.setJWT(jwt) + } + + private init(apiKey: String) { sdkInstances += 1 if sdkInstances > 1 { Logger.sdk.error("Multiple SDK instances created, behaviour is undefined") } + let innerSdk: ClientSDK do { - sdk = try CrossmintClient.sdk(key: apiKey, authManager: authManager) - let authManager = sdk.authManager - self.crossmintWallets = sdk.crossmintWallets() - self.authManager = authManager - self.isProductionEnvironment = sdk.isProductionEnvironment - self.crossmintTEE = CrossmintTEE.start( - auth: authManager, - webProxy: DefaultWebViewCommunicationProxy(), - apiKey: apiKey, - isProductionEnvironment: isProductionEnvironment - ) + innerSdk = try CrossmintClient.sdk(key: apiKey) } catch { Logger.client.error("Invalid Crossmint API key provided: \(error)") fatalError("Invalid Crossmint API key provided. Please verify your API key is a valid client key.") } + + let authManager = innerSdk.authManager + sdk = innerSdk + crossmintWallets = innerSdk.crossmintWallets() + self.authManager = authManager + crossmintService = innerSdk.crossmintService + crossmintTEE = CrossmintTEE.start( + auth: authManager, + webProxy: DefaultWebViewCommunicationProxy(), + apiKey: apiKey, + isProductionEnvironment: innerSdk.crossmintService.isProductionEnvironment + ) } - public func logout() { + public func logout() async { + do { + _ = try await authManager.logout() + } catch { + Logger.sdk.warn("Logout request failed: \(error) — clearing local state anyway") + } crossmintTEE.resetState() } diff --git a/Sources/CrossmintClient/SwiftUI/View+NonCustodialSigner.swift b/Sources/CrossmintClient/SwiftUI/View+NonCustodialSigner.swift index 378bc97..8477a0f 100644 --- a/Sources/CrossmintClient/SwiftUI/View+NonCustodialSigner.swift +++ b/Sources/CrossmintClient/SwiftUI/View+NonCustodialSigner.swift @@ -26,17 +26,13 @@ final class InstanceTracker: ObservableObject, Sendable { } extension View { - public func crossmintNonCustodialSigner(_ sdk: CrossmintSDK) -> some View { - self.modifier(CrossmintNonCustodialSignerViewModifier(sdk: sdk)) + public func crossmintNonCustodialSigner() -> some View { + self.modifier(CrossmintNonCustodialSignerViewModifier()) } } private struct CrossmintNonCustodialSignerViewModifier: ViewModifier { - private let crossmintTEE: CrossmintTEE - - init(sdk: CrossmintSDK) { - crossmintTEE = sdk.crossmintTEE - } + private let crossmintTEE: CrossmintTEE = CrossmintSDK.shared.crossmintTEE func body(content: Content) -> some View { ZStack {