diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MagicSDK.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MagicSDK.xcscheme
new file mode 100644
index 0000000..b7d8911
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/MagicSDK.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 5001d1a..8299177 100644
--- a/README.md
+++ b/README.md
@@ -9,38 +9,9 @@ The Magic CocoaPods SDK version (v8.0.0) is currently out of sync with the Magic
## ⚠️ Removal of `loginWithMagicLink()` ⚠️
As of `v9.0.0`, passcodes (ie. `loginWithSMS()`, `loginWithEmailOTP()`) are replacing Magic Links (ie. `loginWithMagicLink()`) for all of our Mobile SDKs. [Learn more](https://magic.link/docs/auth/login-methods/email/email-link-update-march-2023)
-## Cocoapods
+## Example
-### Set up the local development env
-1. To start the demo app with local development SDK, download following projects
-```bash
-# demo app
-$ git clone https://github.com/magiclabs/magic-ios-demo
-# ios SDK
-$ git clone https://github.com/magiclabs/magic-ios
-```
-
-2. To enable the demo use the local development SDK. Navigate to `magic-ios-demo/Podfile` and edit the following lines.
-This will make pod file install local dependencies instead of the ones distributed.
-
-```ruby
-# Distributed Library on Cocoapods
-# pod 'MagicSDK', '~> 4.0'
-# pod 'MagicExt-OAuth', '~> 1.0'
-
-# Local development library
-pod 'MagicSDK', :path => '../magic-ios/MagicSDK.podspec'
-pod 'MagicExt-OAuth', :path => '../magic-ios-ext/MagicExt-OAuth.podspec'
-```
-
-```bash
-$ cd /YOUR/PATH/TO/magic-ios-demo
-
-# Install dependencies
-$ pod install
-```
-
-3. Open `/YOUR/PATH/TO/magic-ios-demo/magic-ios-demo.xcworkspace` with XCode and try it out!
+A SwiftUI demo app is included in `Example/MagicDemo`. Add the MagicSDK package to your app (Swift Package Manager) and copy the example source files into your target. Set your publishable API key in `MagicDemoApp.swift`.
---
diff --git a/Sources/MagicSDK/Core/Provider/RpcProvider.swift b/Sources/MagicSDK/Core/Provider/RpcProvider.swift
index 8c7b496..c9c2392 100644
--- a/Sources/MagicSDK/Core/Provider/RpcProvider.swift
+++ b/Sources/MagicSDK/Core/Provider/RpcProvider.swift
@@ -9,10 +9,11 @@
import MagicSDK_Web3
import WebKit
import PromiseKit
+import Security
/// A custom Web3 HttpProvider that is specifically configured for use with Magic Links.
public class RpcProvider: NetworkClient, Web3Provider {
-
+
/// Various errors that may occur while processing Web3 requests
public enum ProviderError: Swift.Error {
/// The provider is not configured with an authDelegate
@@ -24,49 +25,106 @@ public class RpcProvider: NetworkClient, Web3Provider {
/// Missing callback
case missingPayloadCallback(json: String)
}
-
+
let overlay: WebViewController
public let urlBuilder: URLBuilder
-
+
+ /// Keychain service name for refresh token storage, namespaced by API key to avoid collisions.
+ private var rtKeychainService: String { "magic_rt_\(urlBuilder.apiKey)" }
+ private let rtKeychainAccount = "refresh_token"
+
required init(urlBuilder: URLBuilder) {
self.overlay = WebViewController(url: urlBuilder)
self.urlBuilder = urlBuilder
super.init()
}
-
+
+ // MARK: - Refresh Token
+
+ private func getRefreshToken() -> String? {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: rtKeychainService,
+ kSecAttrAccount: rtKeychainAccount,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne,
+ ]
+ var result: AnyObject?
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data,
+ let rt = String(data: data, encoding: .utf8) else { return nil }
+ return rt
+ }
+
+ func clearRefreshToken() {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: rtKeychainService,
+ kSecAttrAccount: rtKeychainAccount,
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+
+ private func persistRefreshToken(_ rt: String) {
+ guard let data = rt.data(using: .utf8) else { return }
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: rtKeychainService,
+ kSecAttrAccount: rtKeychainAccount,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ kSecAttrSynchronizable: false,
+ ]
+ let attributes: [CFString: Any] = [kSecValueData: data]
+ if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecItemNotFound {
+ SecItemAdd(query.merging(attributes) { $1 } as CFDictionary, nil)
+ }
+ }
+
// MARK: - Sending Requests
-
+
/// Sends an RPCRequest and parses the result
- /// Web3 Provider protocal conformed
+ /// Web3 Provider protocol conformed
///
/// - Parameters:
/// - request: RPCRequest to send
/// - response: A completion handler for the response. Includes either the result or an error.
public func send(request: RPCRequest, response: @escaping Web3ResponseCompletion) {
let msgType = OutboundMessageType.MAGIC_HANDLE_REQUEST
-
- // Re-assign ID to the payload
- let newRequest = RPCRequest(method: request.method, params: request.params)
-
+
+ // Retrieve persisted refresh token and DPoP JWT
+ let rt = getRefreshToken()
+ let jwt = createJwt()
+
// construct message data
- let eventMessage = MagicRequestData(msgType: "\(msgType.rawValue)-\(urlBuilder.encodedParams)", payload: newRequest, rt: nil, jwt: createJwt())
-
+ let eventMessage = MagicRequestData(
+ msgType: "\(msgType.rawValue)-\(urlBuilder.encodedParams)",
+ payload: request,
+ rt: (jwt != nil) ? rt : nil, // only send rt when jwt is available (matches magic-js behavior)
+ jwt: jwt
+ )
+
// encode to JSON
firstly {
encode(body: eventMessage)
}.done {body throws -> Void in
-
+
let str = try String(body)
-
+
// enqueue and send to webview
- try self.overlay.enqueue(message: str, id: newRequest.id) { ( responseString: String) in
+ try self.overlay.enqueue(message: str, id: request.id) { ( responseString: String) in
guard let jsonData = responseString.data(using: .utf8) else {
throw ProviderError.invalidJsonResponse(json: str)
}
-
+
// Decode JSON string into string
do {
- let rpcResponse = try self.decoder.decode(MagicResponseData>.self, from: jsonData)
+ let rpcResponse = try self.decoder.decode(MagicResponseData>.self, from: jsonData)
+
+ // Persist refresh token if the relayer returned a new one
+ if let newRt = rpcResponse.rt {
+ self.persistRefreshToken(newRt)
+ }
+
let result = Web3Response(rpcResponse: rpcResponse.response)
response(result)
} catch {
@@ -76,7 +134,6 @@ public class RpcProvider: NetworkClient, Web3Provider {
}.catch { error in
let errResponse = Web3Response(error: ProviderError.encodingFailed(error))
response(errResponse)
-// handleRollbarError(error, log: false)
}
}
}
diff --git a/Sources/MagicSDK/Core/Relayer/Types/BasicTypes.swift b/Sources/MagicSDK/Core/Relayer/Types/BasicTypes.swift
index e99605c..ca94105 100644
--- a/Sources/MagicSDK/Core/Relayer/Types/BasicTypes.swift
+++ b/Sources/MagicSDK/Core/Relayer/Types/BasicTypes.swift
@@ -15,10 +15,12 @@ enum InboundMessageType: String, CaseIterable {
case MAGIC_HIDE_OVERLAY
case MAGIC_HANDLE_EVENT
case MAGIC_SEND_PRODUCT_ANNOUNCEMENT
+ case MAGIC_PONG
}
enum OutboundMessageType: String, CaseIterable {
case MAGIC_HANDLE_REQUEST
+ case MAGIC_PING
}
struct MagicRequestData: Codable {
diff --git a/Sources/MagicSDK/Core/Relayer/WebViewController.swift b/Sources/MagicSDK/Core/Relayer/WebViewController.swift
index 646bc46..2aec038 100644
--- a/Sources/MagicSDK/Core/Relayer/WebViewController.swift
+++ b/Sources/MagicSDK/Core/Relayer/WebViewController.swift
@@ -17,7 +17,7 @@ class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler,
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: WebViewController.self)
)
-
+
/// Various errors that may occur while processing Web3 requests
public enum AuthRelayerError: Error {
@@ -40,12 +40,20 @@ class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler,
var overlayReady = false
var webViewFinishLoading = false
- /// Queue and callbackss
+ /// Queue and callbacks
var queue: [String] = []
var messageHandlers: Dictionary = [:]
typealias MessageHandler = (String) throws -> Void
+ // MARK: - Heartbeat
+ private let pingInterval: TimeInterval = 5 * 60 // 5 minutes
+ private let heartbeatInitialDelay: TimeInterval = 60 * 60 // 1 hour
+
+ private var lastPongTime: Date?
+ private var heartbeatTimer: Timer?
+ private var heartbeatDebounceWorkItem: DispatchWorkItem?
+
// MARK: - init
init(url: URLBuilder) {
self.urlBuilder = url
@@ -66,11 +74,12 @@ class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler,
private func dequeue() throws -> Void {
- // Check if UI is appeneded properly to current screen before dequeue
- guard let window = UIApplication.shared.keyWindow else { return try attachWebView() }
+ // Check if UI is appended properly to current screen before dequeue
+ guard let window = try? getKeyWindow() else {
+ return try attachWebView()
+ }
if self.view.isDescendant(of: window) {
-
if !queue.isEmpty && overlayReady && webViewFinishLoading {
let message = queue.removeFirst()
try self.postMessage(message: message)
@@ -96,17 +105,22 @@ class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler,
if payloadStr.contains(InboundMessageType.MAGIC_OVERLAY_READY.rawValue) {
overlayReady = true
+ resetHeartbeatDebounce()
try? self.dequeue()
} else if payloadStr.contains(InboundMessageType.MAGIC_SHOW_OVERLAY.rawValue) {
try bringWebViewToFront()
} else if payloadStr.contains(InboundMessageType.MAGIC_HIDE_OVERLAY.rawValue) {
try sendSubviewToBack()
} else if payloadStr.contains(InboundMessageType.MAGIC_HANDLE_EVENT.rawValue) {
+ resetHeartbeatDebounce()
try handleEvent(payloadStr: payloadStr)
} else if payloadStr.contains(InboundMessageType.MAGIC_HANDLE_RESPONSE.rawValue) {
+ resetHeartbeatDebounce()
try handleResponse(payloadStr: payloadStr)
} else if payloadStr.contains(InboundMessageType.MAGIC_SEND_PRODUCT_ANNOUNCEMENT.rawValue) {
try makeProductAnnouncement(payloadStr: payloadStr)
+ } else if payloadStr.contains(InboundMessageType.MAGIC_PONG.rawValue) {
+ lastPongTime = Date()
}
}
try self.dequeue()
@@ -121,42 +135,52 @@ class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler,
let eventData = payloadStr.data(using: .utf8)!
let eventResponse = try JSONDecoder().decode(MagicResponseData>.self, from: eventData)
- // post event to the obeserver
+ // post event to the observer
let event = eventResponse.response
if let eventName = event.result.event {
- NotificationCenter.default.post(name: Notification.Name.init(eventName), object: nil, userInfo: ["event": event.result])
+ // Re-wrap as MagicEventResult<[AnyValue]> (non-optional params) so EventCenter cast succeeds
+ let result = MagicEventResult<[AnyValue]>(event: eventName, params: event.result.params ?? [], product_announcement: event.result.product_announcement)
+ NotificationCenter.default.post(name: Notification.Name.init(eventName), object: nil, userInfo: ["event": result])
}
}
private func handleResponse(payloadStr: String) throws -> Void {
- /// Take id out from JSON string
- if let range = payloadStr.range(of: "(?<=\"id\":)(.*?)(?=,)", options: .regularExpression) {
+ /// Use JSON parsing to robustly extract the numeric id field
+ guard let data = payloadStr.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let response = json["response"] as? [String: Any],
+ let id = response["id"] as? Int else {
- guard let id = Int(payloadStr[range]) else {
-
- /// throws when response has no matching id
- throw RpcProvider.ProviderError.invalidJsonResponse(json: payloadStr)
+ /// Fallback regex for non-standard formats
+ if let range = payloadStr.range(of: #"(?<="id":)\s*(\d+)"#, options: .regularExpression) {
+ guard let id = Int(payloadStr[range].trimmingCharacters(in: .whitespaces)) else {
+ throw RpcProvider.ProviderError.invalidJsonResponse(json: payloadStr)
+ }
+ if let callback = self.messageHandlers[id] {
+ try callback(payloadStr)
+ self.messageHandlers[id] = nil
+ } else {
+ throw RpcProvider.ProviderError.missingPayloadCallback(json: payloadStr)
+ }
+ return
}
+ throw RpcProvider.ProviderError.invalidJsonResponse(json: payloadStr)
+ }
- // Call callback stored
- if let callback = self.messageHandlers[id] {
- try callback(payloadStr)
- self.messageHandlers[id] = nil
- } else {
-
- /// throws when response couldn't match a callback
- throw RpcProvider.ProviderError.missingPayloadCallback(json: payloadStr)
- }
+ // Call callback stored
+ if let callback = self.messageHandlers[id] {
+ try callback(payloadStr)
+ self.messageHandlers[id] = nil
} else {
- throw RpcProvider.ProviderError.invalidJsonResponse(json: payloadStr)
+ throw RpcProvider.ProviderError.missingPayloadCallback(json: payloadStr)
}
}
-
+
private func makeProductAnnouncement(payloadStr: String) throws {
// Decoding the JSON string into the Payload struct
guard let data = payloadStr.data(using: .utf8) else { return }
-
+
// Define a typealias for the expected payload type
typealias PayloadType = MagicResponseData>
@@ -187,7 +211,9 @@ class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler,
}
let execString = String(format: "window.dispatchEvent(new MessageEvent('message', \(jsonString)));")
- webView.evaluateJavaScript(execString)
+ webView.evaluateJavaScript(execString) { _, err in
+ if let err = err { print("Magic internal error evaluateJavaScript: \(err)") }
+ }
}
@@ -318,12 +344,24 @@ class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler,
}
private func getKeyWindow() throws -> UIWindow {
-
- guard let keyWindow = UIApplication.shared.windows.filter({$0.isKeyWindow}).first else {
- throw AuthRelayerError.topMostWindowNotFound
+ if #available(iOS 15.0, *) {
+ // Prefer foregroundActive, fall back to foregroundInactive (e.g. during launch)
+ let windowScene = UIApplication.shared.connectedScenes
+ .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
+ ?? UIApplication.shared.connectedScenes
+ .first(where: { $0.activationState == .foregroundInactive }) as? UIWindowScene
+ ?? UIApplication.shared.connectedScenes.first as? UIWindowScene
+ guard let scene = windowScene,
+ let window = scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
+ else { throw AuthRelayerError.topMostWindowNotFound }
+ return window
+ } else {
+ guard let keyWindow = UIApplication.shared.windows.first(where: { $0.isKeyWindow })
+ ?? UIApplication.shared.windows.first else {
+ throw AuthRelayerError.topMostWindowNotFound
+ }
+ return keyWindow
}
-
- return keyWindow
}
private func attachWebView() throws -> Void {
@@ -332,17 +370,75 @@ class WebViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler,
keyWindow.addSubview(self.view)
keyWindow.sendSubviewToBack(self.view)
+ }
+
+ // MARK: - Heartbeat
+
+ /// Resets the debounced heartbeat timer. Called whenever the relayer sends any message,
+ /// ensuring the health check only starts after a period of relayer inactivity.
+ private func resetHeartbeatDebounce() {
+ heartbeatDebounceWorkItem?.cancel()
+ stopHeartbeatTimer()
+ lastPongTime = nil
+
+ let workItem = DispatchWorkItem { [weak self] in
+ self?.startHeartbeat()
+ }
+ heartbeatDebounceWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + heartbeatInitialDelay, execute: workItem)
+ }
- // find topmost view controller from the hierarchy and move webview to it
- if var topController = keyWindow.rootViewController {
- while let presentedViewController = topController.presentedViewController {
- topController = presentedViewController
+ /// Begins periodic ping checks after the initial quiet period expires.
+ /// Reloads the webview if the relayer becomes unresponsive.
+ private func startHeartbeat() {
+ stopHeartbeatTimer()
+ var firstPing = true
+
+ heartbeatTimer = Timer.scheduledTimer(withTimeInterval: pingInterval, repeats: true) { [weak self] _ in
+ guard let self = self else { return }
+
+ if let lastPong = self.lastPongTime {
+ let timeSinceLastPong = Date().timeIntervalSince(lastPong)
+ if timeSinceLastPong > self.pingInterval * 2 {
+ // Pong is stale — reload
+ self.reloadWebView()
+ firstPing = true
+ return
+ }
+ } else if !firstPing {
+ // No pong ever received after first ping — reload
+ self.reloadWebView()
+ firstPing = true
+ return
}
- self.didMove(toParent: topController)
+ self.sendPing()
+ firstPing = false
+ }
+ }
- } else {
- throw AuthRelayerError.webviewAttachedFailed
+ private func stopHeartbeatTimer() {
+ heartbeatTimer?.invalidate()
+ heartbeatTimer = nil
+ }
+
+ private func sendPing() {
+ let msgType = "\(OutboundMessageType.MAGIC_PING.rawValue)-\(urlBuilder.encodedParams)"
+ let pingPayload: [String: Any] = ["msgType": msgType, "payload": []]
+ guard let data = try? JSONSerialization.data(withJSONObject: pingPayload),
+ let str = String(data: data, encoding: .utf8) else { return }
+ try? postMessage(message: str)
+ }
+
+ private func reloadWebView() {
+ overlayReady = false
+ webViewFinishLoading = false
+ stopHeartbeatTimer()
+ heartbeatDebounceWorkItem?.cancel()
+ let myURL = URL(string: urlBuilder.url)!
+ let myRequest = URLRequest(url: myURL)
+ DispatchQueue.main.async { [weak self] in
+ self?.webView.load(myRequest)
}
}
}
diff --git a/Sources/MagicSDK/Modules/Auth/AuthConfiguration.swift b/Sources/MagicSDK/Modules/Auth/AuthConfiguration.swift
index ef39eb9..0e5e8b4 100644
--- a/Sources/MagicSDK/Modules/Auth/AuthConfiguration.swift
+++ b/Sources/MagicSDK/Modules/Auth/AuthConfiguration.swift
@@ -10,21 +10,43 @@ import Foundation
public struct LoginWithSmsConfiguration: BaseConfiguration {
-
- ///
+
public var phoneNumber: String
- var showUI = true
-
- public init(phoneNumber: String) {
+ var showUI: Bool
+ var lifespan: Int?
+
+ public init(phoneNumber: String, showUI: Bool = true, lifespan: Int? = nil) {
self.phoneNumber = phoneNumber
+ self.showUI = showUI
+ self.lifespan = lifespan
}
}
public struct LoginWithEmailOTPConfiguration: BaseConfiguration {
-
+
public var email: String
-
- public init(email: String) {
+ var showUI: Bool
+ var deviceCheckUI: Bool
+ var overrides: LoginWithEmailOTPOverrides?
+ var lifespan: Int?
+
+ public init(email: String, showUI: Bool = true, overrides: LoginWithEmailOTPOverrides? = nil, lifespan: Int? = nil) {
self.email = email
+ self.showUI = showUI
+ self.deviceCheckUI = showUI
+ self.overrides = overrides
+ self.lifespan = lifespan
+ }
+}
+
+public struct LoginWithEmailOTPOverrides: BaseConfiguration {
+ var variation: String?
+ var appName: String?
+ var assetUrl: String?
+
+ public init(variation: String? = nil, appName: String? = nil, assetUrl: String? = nil) {
+ self.variation = variation
+ self.appName = appName
+ self.assetUrl = assetUrl
}
}
diff --git a/Sources/MagicSDK/Modules/Auth/AuthModule.swift b/Sources/MagicSDK/Modules/Auth/AuthModule.swift
index 27fc1ed..18ab59c 100644
--- a/Sources/MagicSDK/Modules/Auth/AuthModule.swift
+++ b/Sources/MagicSDK/Modules/Auth/AuthModule.swift
@@ -24,17 +24,76 @@ public class AuthModule: BaseModule {
}
// MARK: - Login with EmailOTP
- public func loginWithEmailOTP (_ configuration: LoginWithEmailOTPConfiguration, response: @escaping Web3ResponseCompletion ) {
+ public func loginWithEmailOTP(_ configuration: LoginWithEmailOTPConfiguration, response: @escaping Web3ResponseCompletion) {
let request = RPCRequest<[LoginWithEmailOTPConfiguration]>(method: AuthMethod.magic_auth_login_with_email_otp.rawValue, params: [configuration])
self.provider.send(request: request, response: response)
}
-
- public func loginWithEmailOTP (_ configuration: LoginWithEmailOTPConfiguration) -> Promise {
+
+ public func loginWithEmailOTP(_ configuration: LoginWithEmailOTPConfiguration) -> Promise {
return Promise { resolver in
loginWithEmailOTP(configuration, response: promiseResolver(resolver))
}
}
-
+
+ /// Event-driven overload for `showUI: false` flows. Returns a `MagicEventPromise`
+ /// that lets callers subscribe to inbound events and emit OTP back to the relayer.
+ ///
+ /// Example:
+ /// ```swift
+ /// magic.auth.loginWithEmailOTP(LoginWithEmailOTPConfiguration(email: email, showUI: false), eventLog: true)
+ /// .on(eventName: LoginWithEmailOTPEvent.emailOTPSent.rawValue) {
+ /// // prompt user for OTP
+ /// }
+ /// .on(eventName: LoginWithEmailOTPEvent.invalidEmailOTP.rawValue) {
+ /// // show error
+ /// }
+ /// .done { didToken in
+ /// // authenticated
+ /// }
+ ///
+ /// // When user submits OTP:
+ /// handle.emit(eventType: LoginWithEmailOTPEvent.verifyEmailOTP.rawValue, arg: otp)
+ /// ```
+ @discardableResult
+ public func loginWithEmailOTP(_ configuration: LoginWithEmailOTPConfiguration, eventLog: Bool) -> MagicEventPromise {
+ // Build the request once so its id is the same one sent to the relayer.
+ let request = RPCRequest<[LoginWithEmailOTPConfiguration]>(method: AuthMethod.magic_auth_login_with_email_otp.rawValue, params: [configuration])
+ let payloadId = request.id
+
+ return MagicEventPromise(eventCenter: magicEventCenter, eventLog: eventLog, emitHandler: { [weak self] eventType, arg in
+ self?.sendIntermediaryEvent(payloadId: payloadId, eventType: eventType, arg: arg)
+ }) { [weak self] resolver in
+ self?.provider.send(request: request, response: promiseResolver(resolver))
+ }
+ }
+
+ public enum LoginWithEmailOTPEvent: String {
+ // Inbound — received from relayer (email OTP)
+ case emailOTPSent = "email-otp-sent"
+ case invalidEmailOTP = "invalid-email-otp"
+ case expiredEmailOTP = "expired-email-otp"
+ case loginThrottled = "login-throttled"
+ case maxAttemptsReached = "max-attempts-reached"
+ // Inbound — received from relayer (MFA)
+ case mfaSentHandle = "mfa-sent-handle"
+ case invalidMfaOTP = "invalid-mfa-otp"
+ case recoveryCodeSentHandle = "recovery-code-sent-handle"
+ case invalidRecoveryCode = "invalid-recovery-code"
+ case recoveryCodeSuccess = "recovery-code-success"
+ // Inbound — received from relayer (device verification)
+ case deviceNeedsApproval = "device-needs-approval"
+ case deviceVerificationEmailSent = "device-verification-email-sent"
+ case deviceApproved = "device-approved"
+ case deviceVerificationLinkExpired = "device-verification-link-expired"
+ // Outbound — emitted by SDK to relayer
+ case verifyEmailOTP = "verify-email-otp"
+ case verifyMFACode = "verify-mfa-code"
+ case verifyRecoveryCode = "verify-recovery-code"
+ case lostDevice = "lost-device"
+ case deviceRetry = "device-retry"
+ case cancel = "cancel"
+ }
+
public enum LoginEmailOTPLinkEvent: String {
case emailNotDeliverable = "email-not-deliverable"
case emailSent = "email-sent"
diff --git a/Sources/MagicSDK/Modules/BaseModule.swift b/Sources/MagicSDK/Modules/BaseModule.swift
index 9c71f4a..c7e93de 100644
--- a/Sources/MagicSDK/Modules/BaseModule.swift
+++ b/Sources/MagicSDK/Modules/BaseModule.swift
@@ -11,13 +11,36 @@ import MagicSDK_Web3
import PromiseKit
open class BaseModule {
-
+
public let provider: RpcProvider
public let magicEventCenter = EventCenter()
-
+
public init(rpcProvider: RpcProvider) {
self.provider = rpcProvider
}
+
+ /// Sends a `magic_intermediary_event` back to the relayer, mirroring
+ /// `createIntermediaryEvent` in magic-js. Used by event-driven flows
+ /// (e.g. submitting OTP when showUI is false).
+ /// `args` mirrors the JS SDK: a single scalar value (string, int, bool) or nil.
+ /// The relayer casts `eventPayload.args` directly to the expected type — not an array.
+ func sendIntermediaryEvent(payloadId: Int, eventType: String, arg: Any? = nil) {
+ struct IntermediaryParams: Codable {
+ let payloadId: Int
+ let eventType: String
+ let args: AnyValue?
+ }
+ let argValue: AnyValue? = {
+ guard let arg = arg else { return nil }
+ if let s = arg as? String { return AnyValue(valueType: .string(s)) }
+ if let i = arg as? Int { return AnyValue(valueType: .int(i)) }
+ if let b = arg as? Bool { return AnyValue(valueType: .bool(b)) }
+ return nil
+ }()
+ let params = IntermediaryParams(payloadId: payloadId, eventType: eventType, args: argValue)
+ let request = RPCRequest<[IntermediaryParams]>(method: "magic_intermediary_event", params: [params])
+ provider.send(request: request) { (_: Web3Response) in }
+ }
}
public func promiseResolver(_ resolver: Resolver) -> (_ result: Web3Response) -> Void {
diff --git a/Sources/MagicSDK/Modules/EVM/EVMConfiguration.swift b/Sources/MagicSDK/Modules/EVM/EVMConfiguration.swift
new file mode 100644
index 0000000..cc7ae10
--- /dev/null
+++ b/Sources/MagicSDK/Modules/EVM/EVMConfiguration.swift
@@ -0,0 +1,40 @@
+//
+// EVMConfiguration.swift
+// MagicSDK
+//
+
+import Foundation
+
+/// Configuration for switching the active EVM chain.
+public struct SwitchChainConfiguration: BaseConfiguration {
+ public let chainId: Int
+
+ public init(chainId: Int) {
+ self.chainId = chainId
+ }
+}
+
+/// The network info returned after a successful chain switch.
+public struct SwitchChainResult: MagicResponse {
+ /// Network identifier — may be a chain name string or nil if the response omits it.
+ public let network: SwitchChainNetwork?
+}
+
+public struct SwitchChainNetwork: Codable {
+ public let rpcUrl: String?
+ public let chainId: Int?
+ public let chainType: String?
+}
+
+/// Configuration for a single EVM network used when registering available chains.
+public struct EVMNetworkConfiguration: Codable {
+ public let rpcUrl: String
+ public let chainId: Int?
+ public let isDefault: Bool
+
+ public init(rpcUrl: String, chainId: Int? = nil, isDefault: Bool = false) {
+ self.rpcUrl = rpcUrl
+ self.chainId = chainId
+ self.isDefault = isDefault
+ }
+}
diff --git a/Sources/MagicSDK/Modules/EVM/EVMExtension.swift b/Sources/MagicSDK/Modules/EVM/EVMExtension.swift
new file mode 100644
index 0000000..4f77229
--- /dev/null
+++ b/Sources/MagicSDK/Modules/EVM/EVMExtension.swift
@@ -0,0 +1,44 @@
+//
+// EVMExtension.swift
+// MagicSDK
+//
+
+import Foundation
+import MagicSDK_Web3
+import PromiseKit
+
+/// EVM extension providing multi-chain support, including dynamic chain switching.
+/// Mirrors the `@magic-ext/evm` package in magic-js.
+public class EVMExtension: BaseModule {
+
+ // MARK: - Switch Chain
+
+ /// Switches the active EVM chain to the one identified by `chainId`.
+ ///
+ /// - Parameters:
+ /// - configuration: Contains the target `chainId`.
+ /// - response: Completion handler called with the new network info or an error.
+ public func switchChain(_ configuration: SwitchChainConfiguration, response: @escaping Web3ResponseCompletion) {
+ let request = RPCRequest<[SwitchChainConfiguration]>(
+ method: EVMMethod.evm_switchChain.rawValue,
+ params: [configuration]
+ )
+ self.provider.send(request: request, response: response)
+ }
+
+ /// Promise-based overload of `switchChain`.
+ public func switchChain(_ configuration: SwitchChainConfiguration) -> Promise {
+ return Promise { resolver in
+ switchChain(configuration, response: promiseResolver(resolver))
+ }
+ }
+}
+
+// MARK: - Magic extension
+
+public extension Magic {
+ /// Access EVM-specific methods such as `switchChain`.
+ var evm: EVMExtension {
+ return EVMExtension(rpcProvider: self.rpcProvider)
+ }
+}
diff --git a/Sources/MagicSDK/Modules/EVM/EVMMethod.swift b/Sources/MagicSDK/Modules/EVM/EVMMethod.swift
new file mode 100644
index 0000000..7e06d7b
--- /dev/null
+++ b/Sources/MagicSDK/Modules/EVM/EVMMethod.swift
@@ -0,0 +1,10 @@
+//
+// EVMMethod.swift
+// MagicSDK
+//
+
+import Foundation
+
+internal enum EVMMethod: String {
+ case evm_switchChain
+}
diff --git a/Sources/MagicSDK/Modules/Event/EventCenter.swift b/Sources/MagicSDK/Modules/Event/EventCenter.swift
index fb9bbad..1e57de4 100644
--- a/Sources/MagicSDK/Modules/Event/EventCenter.swift
+++ b/Sources/MagicSDK/Modules/Event/EventCenter.swift
@@ -9,36 +9,64 @@ import Foundation
import PromiseKit
public class EventCenter {
-
- enum Error: Swift.Error{
+
+ enum Error: Swift.Error {
case eventCallbackMissing
}
private var eventLog = false
-
- private typealias EventCompletion = () -> Void
- private var eventHandlerDict: Dictionary = [:]
- func addOnceObserver (eventName: String, eventLog: Bool, completion: @escaping () -> Void) -> Void {
- NotificationCenter.default.addObserver(self, selector: #selector(self.onDidReceiveEventOnce(_:)), name: Notification.Name.init(eventName), object: nil)
+ private typealias EventCompletion = ([AnyValue]?) -> Void
+ private var onceHandlerDict: Dictionary = [:]
+ private var persistentHandlerDict: Dictionary = [:]
+
+ func addOnceObserver(eventName: String, eventLog: Bool, completion: @escaping () -> Void) {
+ addOnceObserver(eventName: eventName, eventLog: eventLog) { (_: [AnyValue]?) in completion() }
+ }
+
+ func addOnceObserver(eventName: String, eventLog: Bool, completion: @escaping ([AnyValue]?) -> Void) {
+ NotificationCenter.default.addObserver(self, selector: #selector(self.onDidReceiveEvent(_:)), name: Notification.Name(eventName), object: nil)
+ self.eventLog = eventLog
+ onceHandlerDict[eventName] = completion
+ }
+
+ func addPersistentObserver(eventName: String, eventLog: Bool, completion: @escaping () -> Void) {
+ addPersistentObserver(eventName: eventName, eventLog: eventLog) { (_: [AnyValue]?) in completion() }
+ }
+
+ func addPersistentObserver(eventName: String, eventLog: Bool, completion: @escaping ([AnyValue]?) -> Void) {
+ NotificationCenter.default.addObserver(self, selector: #selector(self.onDidReceiveEvent(_:)), name: Notification.Name(eventName), object: nil)
self.eventLog = eventLog
- eventHandlerDict[eventName] = completion
+ persistentHandlerDict[eventName] = completion
}
- /// Recieve events
- @objc func onDidReceiveEventOnce(_ notification: Notification) {
+ @objc func onDidReceiveEvent(_ notification: Notification) {
+ if let eventResult = (notification.userInfo?["event"]) as? MagicEventResult<[AnyValue]>,
+ let event = eventResult.event {
- if let eventResult = (notification.userInfo?["event"]) as? MagicEventResult<[AnyValue]>, let event = eventResult.event, let handler = eventHandlerDict[event] {
-
- if (eventLog) {
+ if eventLog {
print("MagicSDK Event: \(eventResult)")
}
-
- NotificationCenter.default.removeObserver(self, name: Notification.Name(event), object: nil)
- handler()
- } else {
-// handleRollbarError(Error.eventCallbackMissing)
+
+ if let handler = onceHandlerDict[event] {
+ NotificationCenter.default.removeObserver(self, name: Notification.Name(event), object: nil)
+ onceHandlerDict.removeValue(forKey: event)
+ handler(eventResult.params)
+ } else if let handler = persistentHandlerDict[event] {
+ handler(eventResult.params)
+ }
+ }
+ }
+
+ func removeAllObservers() {
+ for eventName in onceHandlerDict.keys {
+ NotificationCenter.default.removeObserver(self, name: Notification.Name(eventName), object: nil)
}
+ for eventName in persistentHandlerDict.keys {
+ NotificationCenter.default.removeObserver(self, name: Notification.Name(eventName), object: nil)
+ }
+ onceHandlerDict.removeAll()
+ persistentHandlerDict.removeAll()
}
-
- init (){}
+
+ init() {}
}
diff --git a/Sources/MagicSDK/Modules/Event/EventPromise.swift b/Sources/MagicSDK/Modules/Event/EventPromise.swift
index 5216aeb..fb6e2b2 100644
--- a/Sources/MagicSDK/Modules/Event/EventPromise.swift
+++ b/Sources/MagicSDK/Modules/Event/EventPromise.swift
@@ -9,25 +9,77 @@ import Foundation
import PromiseKit
public class MagicEventPromise {
-
+
private var donePromise: Promise
private var eventCenter: EventCenter
private var eventLog = false
-
- typealias EventCompletion = () -> Void
+ private var emitHandler: ((_ eventType: String, _ arg: Any?) -> Void)?
+ private var errorHandler: ((_ error: Error) -> Void)?
- public func once(eventName: String, completion: @escaping () -> Void) -> MagicEventPromise {
+ /// Subscribe to an inbound event (fires once, then unregisters).
+ @discardableResult
+ public func on(eventName: String, completion: @escaping () -> Void) -> MagicEventPromise {
eventCenter.addOnceObserver(eventName: eventName, eventLog: eventLog, completion: completion)
return self
}
-
- public func done(on: DispatchQueue? = conf.Q.return, flags: DispatchWorkItemFlags? = nil, _ body: @escaping(T) throws -> Void) -> Promise {
+
+ /// Subscribe to an inbound event whose first param is a String (fires once).
+ @discardableResult
+ public func on(eventName: String, completion: @escaping (String?) -> Void) -> MagicEventPromise {
+ eventCenter.addOnceObserver(eventName: eventName, eventLog: eventLog) { params in
+ completion(params?.first?.string)
+ }
+ return self
+ }
+
+ /// Subscribe to an inbound event that may fire multiple times (e.g. invalid OTP retries).
+ @discardableResult
+ public func onPersistent(eventName: String, completion: @escaping () -> Void) -> MagicEventPromise {
+ eventCenter.addPersistentObserver(eventName: eventName, eventLog: eventLog, completion: completion)
+ return self
+ }
+
+ /// Subscribe to errors — mirrors magic-js's `.on('error', handler)` on PromiEvent.
+ /// Called whenever the underlying promise rejects (RPC errors, network failures, etc.).
+ @discardableResult
+ public func onError(_ handler: @escaping (_ error: Error) -> Void) -> MagicEventPromise {
+ errorHandler = handler
+ return self
+ }
+
+ /// Emit an intermediary event back to the relayer (e.g. submit OTP).
+ /// Pass the scalar value directly — not wrapped in an array.
+ public func emit(eventType: String, arg: Any? = nil) {
+ emitHandler?(eventType, arg)
+ }
+
+ public func done(on: DispatchQueue? = conf.Q.return, flags: DispatchWorkItemFlags? = nil, _ body: @escaping (T) throws -> Void) -> Promise {
return self.donePromise.done(on: on, flags: flags, body)
}
-
- init (eventCenter: EventCenter, eventLog: Bool, _ resolver: @escaping (_ resolver: Resolver) -> Void) {
- self.donePromise = Promise(resolver: resolver)
+
+ @discardableResult
+ public func `catch`(on: DispatchQueue? = conf.Q.return, _ body: @escaping (Error) -> Void) -> MagicEventPromise {
+ donePromise.catch(on: on, body)
+ return self
+ }
+
+ init(eventCenter: EventCenter, eventLog: Bool, emitHandler: ((_ eventType: String, _ arg: Any?) -> Void)? = nil, _ resolver: @escaping (_ resolver: Resolver) -> Void) {
+ let (promise, seal) = Promise.pending()
+ self.donePromise = promise
self.eventCenter = eventCenter
self.eventLog = eventLog
+ self.emitHandler = emitHandler
+
+ // Run the resolver and wire error emission on rejection — mirrors PromiEvent's
+ // `.emit('error', err)` in magic-js before re-throwing to `.catch` callers.
+ Promise(resolver: resolver).pipe { result in
+ switch result {
+ case .fulfilled(let value):
+ seal.fulfill(value)
+ case .rejected(let error):
+ self.errorHandler?(error)
+ seal.reject(error)
+ }
+ }
}
}
diff --git a/Sources/MagicSDK/Modules/User/UserConfiguration.swift b/Sources/MagicSDK/Modules/User/UserConfiguration.swift
index c4f822a..283f8ff 100644
--- a/Sources/MagicSDK/Modules/User/UserConfiguration.swift
+++ b/Sources/MagicSDK/Modules/User/UserConfiguration.swift
@@ -42,8 +42,10 @@ public class UpdateEmailConfiguration: BaseConfiguration {
public class RecoverAccountConfiguration: BaseConfiguration {
var email: String
-
- public init(email: String){
+ var showUI: Bool
+
+ public init(email: String, showUI: Bool = true) {
self.email = email
+ self.showUI = showUI
}
}
diff --git a/Sources/MagicSDK/Modules/User/UserMethod.swift b/Sources/MagicSDK/Modules/User/UserMethod.swift
index cad7cb6..eddfb3e 100644
--- a/Sources/MagicSDK/Modules/User/UserMethod.swift
+++ b/Sources/MagicSDK/Modules/User/UserMethod.swift
@@ -17,7 +17,10 @@ internal enum UserMethod: String, CaseIterable {
case magic_auth_logout
case magic_auth_settings
case magic_auth_update_email
- case magic_auth_is_logged_in
+ case magic_is_logged_in
case magic_auth_update_phone_number
case magic_auth_recover_account
+ case magic_auth_enable_mfa_flow
+ case magic_auth_disable_mfa_flow
+ case magic_auth_get_metadata
}
diff --git a/Sources/MagicSDK/Modules/User/UserModule.swift b/Sources/MagicSDK/Modules/User/UserModule.swift
index 19aef0e..ff620e7 100644
--- a/Sources/MagicSDK/Modules/User/UserModule.swift
+++ b/Sources/MagicSDK/Modules/User/UserModule.swift
@@ -59,7 +59,7 @@ public class UserModule: BaseModule {
IsLogged In
*/
public func isLoggedIn(response: @escaping Web3ResponseCompletion) {
- let request = BasicRPCRequest(method: UserMethod.magic_auth_is_logged_in.rawValue, params: [])
+ let request = BasicRPCRequest(method: UserMethod.magic_is_logged_in.rawValue, params: [])
self.provider.send(request: request, response: response)
}
@@ -93,12 +93,15 @@ public class UserModule: BaseModule {
/**
Logout
*/
- public func logout (response: @escaping Web3ResponseCompletion) {
+ public func logout(response: @escaping Web3ResponseCompletion) {
let request = BasicRPCRequest(method: UserMethod.magic_auth_logout.rawValue, params: [])
- self.provider.send(request: request, response: response)
+ self.provider.send(request: request) { [weak self] (result: Web3Response) in
+ self?.provider.clearRefreshToken()
+ response(result)
+ }
}
-
- public func logout() -> Promise {
+
+ public func logout() -> Promise {
return Promise { resolver in
logout(response: promiseResolver(resolver))
}
@@ -136,13 +139,41 @@ public class UserModule: BaseModule {
*/
public func recoverAccount(_ configuration: RecoverAccountConfiguration, response: @escaping Web3ResponseCompletion) {
let request = RPCRequest<[RecoverAccountConfiguration]>(method: UserMethod.magic_auth_recover_account.rawValue, params: [configuration])
-
+
return self.provider.send(request: request, response: response)
}
-
+
public func recoverAccount(_ configuration: RecoverAccountConfiguration) -> Promise {
return Promise { resolver in
recoverAccount(configuration, response: promiseResolver(resolver))
}
}
+
+ /**
+ enableMFA
+ */
+ public func enableMFA(response: @escaping Web3ResponseCompletion) {
+ let request = BasicRPCRequest(method: UserMethod.magic_auth_enable_mfa_flow.rawValue, params: [])
+ self.provider.send(request: request, response: response)
+ }
+
+ public func enableMFA() -> Promise {
+ return Promise { resolver in
+ enableMFA(response: promiseResolver(resolver))
+ }
+ }
+
+ /**
+ disableMFA
+ */
+ public func disableMFA(response: @escaping Web3ResponseCompletion) {
+ let request = BasicRPCRequest(method: UserMethod.magic_auth_disable_mfa_flow.rawValue, params: [])
+ self.provider.send(request: request, response: response)
+ }
+
+ public func disableMFA() -> Promise {
+ return Promise { resolver in
+ disableMFA(response: promiseResolver(resolver))
+ }
+ }
}
diff --git a/Sources/MagicSDK/Modules/Wallet/WalletConfiguration.swift b/Sources/MagicSDK/Modules/Wallet/WalletConfiguration.swift
index bf85b42..0464a30 100644
--- a/Sources/MagicSDK/Modules/Wallet/WalletConfiguration.swift
+++ b/Sources/MagicSDK/Modules/Wallet/WalletConfiguration.swift
@@ -25,8 +25,9 @@ public struct RequestUserInfoWithUIConfiguration: BaseConfiguration {
public struct WalletUserInfoScope: Codable {
var email: WalletUserInfoEmailOptions
-
+
enum CodingKeys: String, CodingKey {
case email
}
}
+
diff --git a/Sources/MagicSDK/Utilities/SEKP.swift b/Sources/MagicSDK/Utilities/SEKP.swift
index afb412f..57f3ebb 100644
--- a/Sources/MagicSDK/Utilities/SEKP.swift
+++ b/Sources/MagicSDK/Utilities/SEKP.swift
@@ -36,13 +36,18 @@ func createP256KeyInSE () throws -> SecureEnclave.P256.Signing.PrivateKey {
func storeKeyToKeyChain(_ key: T) throws {
// Treat the key data as a generic password.
+ // kSecAttrAccessibleWhenUnlockedThisDeviceOnly ensures the item:
+ // • is NOT included in device backups (encrypted or otherwise)
+ // • is NOT synced via iCloud Keychain
+ // • is bound to this specific device, matching the non-exportable
+ // guarantee of the Secure Enclave private key it references.
let query = [kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
- kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked,
+ kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
+ kSecAttrSynchronizable: kCFBooleanFalse!,
kSecUseDataProtectionKeychain: true,
kSecValueData: key.rawRepresentation] as [String: Any]
-
// Add the key data.
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
@@ -50,14 +55,14 @@ func storeKeyToKeyChain(_ key: T) throws {
}
}
-func retrieveKeyFromKeyChain () throws -> SecureEnclave.P256.Signing.PrivateKey {
+func retrieveKeyFromKeyChain() throws -> SecureEnclave.P256.Signing.PrivateKey {
// Seek a generic password with the given account.
let query = [kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
+ kSecAttrSynchronizable: kCFBooleanFalse!,
kSecUseDataProtectionKeychain: true,
kSecReturnData: true] as [String: Any]
-
// Find and cast the result as data.
var item: CFTypeRef?
switch SecItemCopyMatching(query as CFDictionary, &item) {
@@ -72,6 +77,7 @@ func retrieveKeyFromKeyChain () throws -> SecureEnclave.P256.Signing.PrivateKey
func deleteKeyFromKeyChain() throws {
let query = [kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
+ kSecAttrSynchronizable: kCFBooleanFalse!,
kSecUseDataProtectionKeychain: true] as [String: Any]
let status = SecItemDelete(query as CFDictionary)