Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)")
Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ struct PlaygroundView: View {
}

private func signOut() async {
try? await crossmintAuthManager.logout()
try? await CrossmintSDK.shared.logout()
await CrossmintSDK.shared.logout()
authenticationStatus = .nonAuthenticated
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/CrossmintClient/ClientSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import Wallet

public protocol ClientSDK {
func crossmintWallets() -> CrossmintWallets
var authManager: AuthManager { get }
var authManager: CrossmintAuthManager { get }
var crossmintService: CrossmintService { get }
}
2 changes: 1 addition & 1 deletion Sources/CrossmintClient/CrossmintClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public actor CrossmintClient {
self.apiKey = apiKey
}

public static func sdk(key: String, authManager: AuthManager? = nil) throws -> ClientSDK {
public static func sdk(key: String, authManager: CrossmintAuthManager? = nil) throws -> ClientSDK {
lock.lock()
defer { lock.unlock() }

Expand Down
4 changes: 2 additions & 2 deletions Sources/CrossmintClient/CrossmintClientSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ public final class CrossmintClientSDK: ClientSDK, Sendable {
private let secureStorage: SecureStorage
private let secureWalletStorage: SecureWalletStorage
public let crossmintService: CrossmintService
public let authManager: any CrossmintAuth.AuthManager
public 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 {
Expand Down
109 changes: 65 additions & 44 deletions Sources/CrossmintClient/SwiftUI/CrossmintSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,39 @@ import Web

@MainActor
final public class CrossmintSDK: ObservableObject {
nonisolated(unsafe) 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 {
#if DEBUG
if _shared == nil, let envApiKey = ProcessInfo.processInfo.environment["CROSSMINT_API_KEY"] {
Logger.client.info("Using API key from the environment variable.")
configure(apiKey: envApiKey)
}
#endif
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Outdated
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
private let sdk: ClientSDK
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Outdated

public let crossmintWallets: CrossmintWallets
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Outdated
public let authManager: AuthManager
public let authManager: CrossmintAuthManager
public let crossmintService: CrossmintService

let crossmintTEE: CrossmintTEE
Expand All @@ -60,46 +63,64 @@ final public class CrossmintSDK: ObservableObject {
crossmintService.isProductionEnvironment
}

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"
)
/// 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, authManager: AuthManager? = nil) {
private struct Components {
let sdk: ClientSDK
let wallets: CrossmintWallets
let authManager: CrossmintAuthManager
let service: CrossmintService
let tee: CrossmintTEE
}
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Outdated

private init(apiKey: String) {
sdkInstances += 1
if sdkInstances > 1 {
Logger.sdk.error("Multiple SDK instances created, behaviour is undefined")
}
let components = Self.makeComponents(apiKey: apiKey)
sdk = components.sdk
crossmintWallets = components.wallets
authManager = components.authManager
crossmintService = components.service
crossmintTEE = components.tee
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is what I mean, I think it stems also from the try/catch maybe? Which step can actually throw? If we want to condense the code, i'd be thinking more:

do {
  let sdk = try CrossmintClient.sdk(key: apiKey)
} catch {
  ...
}

initialize(with: sdk)

with initialize being

self.crossmintWallets = sdk.crossmintWallets()
self.authManager = sdl.authManager
self.crossmintService = sdk.crossmintService
self.crossmintTEE = CrossmintTEE.start(
  ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, on a second look the whole Components was just some unnecessary ceremony. I've inlined everything into the initializer

}

private static func makeComponents(apiKey: String) -> Components {
do {
sdk = try CrossmintClient.sdk(key: apiKey, authManager: authManager)
let sdk = try CrossmintClient.sdk(key: apiKey)
let authManager = sdk.authManager
self.crossmintWallets = sdk.crossmintWallets()
self.authManager = authManager
self.crossmintService = sdk.crossmintService
self.crossmintTEE = CrossmintTEE.start(
auth: authManager,
webProxy: DefaultWebViewCommunicationProxy(),
apiKey: apiKey,
isProductionEnvironment: sdk.crossmintService.isProductionEnvironment
return Components(
sdk: sdk,
wallets: sdk.crossmintWallets(),
authManager: authManager,
service: sdk.crossmintService,
tee: CrossmintTEE.start(
auth: authManager,
webProxy: DefaultWebViewCommunicationProxy(),
apiKey: apiKey,
isProductionEnvironment: sdk.crossmintService.isProductionEnvironment
)
)
} 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.")
}
}

public func logout() async throws {
public func logout() async {
do {
_ = try await authManager.logout()
} catch {
Logger.sdk.warn("Logout request failed: \(error) — clearing local state anyway")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what can happen here if we're not logged out but clear local state? What's the difference?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the intent was to avoid locking users out if the logout endpoint request fails. But on the current implementation, if that request throws, we'd also never clear the user's JWT and refresh tokens from keychain, which could lead to the user getting automatically logged in on the next app launch, if the client doesn't have any safeguards to that

the only part that can throw is the logout network call, so we could clear the credentials before making the request instead, so even if that fails, the client would still lose access to the JWT and the user wouldn't end up accidentally being signed in again, regardless of the request result

}
crossmintTEE.resetState()
}

Expand Down
12 changes: 3 additions & 9 deletions Sources/CrossmintClient/SwiftUI/View+NonCustodialSigner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,15 @@ 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
}

func body(content: Content) -> some View {
ZStack {
HiddenEmailSignersView(crossmintTEE: crossmintTEE)
HiddenEmailSignersView(crossmintTEE: CrossmintSDK.shared.crossmintTEE)
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Outdated
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Outdated
.environmentObject(InstanceTracker(name: "HiddenEmailSignersView"))
content
}
Expand Down
Loading