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)