diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index bcf3351461..faf706a09c 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -42,6 +42,7 @@ final class DependencyContainer { var deepLinkRouter: DeepLinkRouter! var attributionFetcher: AttributionFetcher! let permissionHandler = PermissionHandler() + let customCallbackRegistry = CustomCallbackRegistry() // swiftlint:enable implicitly_unwrapped_optional let paywallArchiveManager = PaywallArchiveManager() @@ -266,7 +267,8 @@ extension DependencyContainer: ViewControllerFactory { let messageHandler = PaywallMessageHandler( receiptManager: receiptManager, factory: self, - permissionHandler: permissionHandler + permissionHandler: permissionHandler, + customCallbackRegistry: customCallbackRegistry ) let webView = SWWebView( isMac: deviceHelper.isMac, diff --git a/Sources/SuperwallKit/Paywall/Presentation/CustomCallback.swift b/Sources/SuperwallKit/Paywall/Presentation/CustomCallback.swift new file mode 100644 index 0000000000..3fdf4bfc8d --- /dev/null +++ b/Sources/SuperwallKit/Paywall/Presentation/CustomCallback.swift @@ -0,0 +1,60 @@ +// +// CustomCallback.swift +// SuperwallKit +// +// Created by Ian Rumac on 2025. +// + +import Foundation + +/// The behavior of a custom callback request. +public enum CustomCallbackBehavior: String, Decodable { + /// The paywall waits for the callback to complete before continuing. + case blocking + /// The paywall continues immediately; callback triggers onSuccess/onFailure. + case nonBlocking = "non-blocking" +} + +/// A custom callback request from the paywall. +public struct CustomCallback { + /// The name of the callback. + public let name: String + + /// Optional variables passed with the callback. + public let variables: [String: Any]? + + public init(name: String, variables: [String: Any]? = nil) { + self.name = name + self.variables = variables + } +} + +/// The status of a custom callback result. +public enum CustomCallbackResultStatus: String { + case success + case failure +} + +/// The result of handling a custom callback. +public struct CustomCallbackResult { + /// The status of the callback result. + public let status: CustomCallbackResultStatus + + /// Optional data to send back to the paywall. + public let data: [String: Any]? + + public init(status: CustomCallbackResultStatus, data: [String: Any]? = nil) { + self.status = status + self.data = data + } + + /// Creates a success result with optional data. + public static func success(data: [String: Any]? = nil) -> CustomCallbackResult { + return CustomCallbackResult(status: .success, data: data) + } + + /// Creates a failure result with optional data. + public static func failure(data: [String: Any]? = nil) -> CustomCallbackResult { + return CustomCallbackResult(status: .failure, data: data) + } +} diff --git a/Sources/SuperwallKit/Paywall/Presentation/CustomCallbackRegistry.swift b/Sources/SuperwallKit/Paywall/Presentation/CustomCallbackRegistry.swift new file mode 100644 index 0000000000..5e273346b7 --- /dev/null +++ b/Sources/SuperwallKit/Paywall/Presentation/CustomCallbackRegistry.swift @@ -0,0 +1,47 @@ +// +// CustomCallbackRegistry.swift +// SuperwallKit +// +// Created by Ian Rumac on 2025. +// + +import Foundation + +/// Thread-safe registry for custom callback handlers, keyed by paywall identifier. +final class CustomCallbackRegistry: @unchecked Sendable { + private var handlers: [String: (CustomCallback) async -> CustomCallbackResult] = [:] + private let lock = NSLock() + + /// Registers a callback handler for a specific paywall identifier. + func register( + paywallIdentifier: String, + handler: @escaping (CustomCallback) async -> CustomCallbackResult + ) { + lock.lock() + defer { lock.unlock() } + handlers[paywallIdentifier] = handler + } + + /// Unregisters the callback handler for a specific paywall identifier. + func unregister(paywallIdentifier: String) { + lock.lock() + defer { lock.unlock() } + handlers.removeValue(forKey: paywallIdentifier) + } + + /// Gets the callback handler for a specific paywall identifier. + func getHandler( + paywallIdentifier: String + ) -> ((CustomCallback) async -> CustomCallbackResult)? { + lock.lock() + defer { lock.unlock() } + return handlers[paywallIdentifier] + } + + /// Clears all registered handlers. + func clear() { + lock.lock() + defer { lock.unlock() } + handlers.removeAll() + } +} diff --git a/Sources/SuperwallKit/Paywall/Presentation/PaywallPresentationHandler.swift b/Sources/SuperwallKit/Paywall/Presentation/PaywallPresentationHandler.swift index d68eab82c9..f3ac62425f 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PaywallPresentationHandler.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PaywallPresentationHandler.swift @@ -39,6 +39,9 @@ public final class PaywallPresentationHandler: NSObject { /// An objective-c only block called when an error occurred while trying to present a paywall. var onSkipHandlerObjc: ((PaywallSkippedReasonObjc) -> Void)? + /// A block called when the paywall requests a custom callback. + var onCustomCallbackHandler: ((CustomCallback) async -> CustomCallbackResult)? + /// Sets the handler that will be called when the paywall did present. /// /// - Parameter handler: A block that accepts a ``PaywallInfo`` object associated with @@ -111,4 +114,15 @@ public final class PaywallPresentationHandler: NSObject { public func onSkip(_ handler: @escaping (PaywallSkippedReasonObjc) -> Void) { self.onSkipHandlerObjc = handler } + + /// Sets the handler that will be called when the paywall requests a custom callback. + /// + /// Use this to handle custom callbacks from your paywall. The callback receives a ``CustomCallback`` + /// object containing the callback name and any variables, and should return a ``CustomCallbackResult`` + /// indicating success or failure. + /// + /// - Parameter handler: An async block that accepts a ``CustomCallback`` and returns a ``CustomCallbackResult``. + public func onCustomCallback(_ handler: @escaping (CustomCallback) async -> CustomCallbackResult) { + self.onCustomCallbackHandler = handler + } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift index 605ed76113..f52fcea27d 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift @@ -131,6 +131,13 @@ extension Superwall { receiveValue: { state in switch state { case .presented(let paywallInfo): + // Register custom callback handler if provided + if let callbackHandler = handler?.onCustomCallbackHandler { + self?.dependencyContainer.customCallbackRegistry.register( + paywallIdentifier: paywallInfo.identifier, + handler: callbackHandler + ) + } handler?.onPresentHandler?(paywallInfo) case let .willDismiss(paywallInfo, paywallResult): if let handler = handler?.onWillDismissHandler { @@ -143,6 +150,10 @@ extension Superwall { ) } case let .dismissed(paywallInfo, paywallResult): + // Unregister custom callback handler + self?.dependencyContainer.customCallbackRegistry.unregister( + paywallIdentifier: paywallInfo.identifier + ) if let handler = handler?.onDismissHandler { handler(paywallInfo, paywallResult) } else { diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift index 63d1777e82..d230a0ee3d 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift @@ -58,6 +58,12 @@ enum PaywallMessage: Decodable, Equatable { case initiateWebCheckout(contextId: String) case requestStoreReview(ReviewType) case requestPermission(permissionType: PermissionType, requestId: String) + case requestCallback( + requestId: String, + name: String, + behavior: CustomCallbackBehavior, + variables: JSON? + ) // All cases below here are sent from device to paywall case paywallClose @@ -98,6 +104,7 @@ enum PaywallMessage: Decodable, Equatable { case requestStoreReview = "request_store_review" case scheduleNotification = "schedule_notification" case requestPermission = "request_permission" + case requestCallback = "request_callback" } // Everyone write to eventName, other may use the remaining keys @@ -123,6 +130,8 @@ enum PaywallMessage: Decodable, Equatable { case delay case permissionType case requestId + case behavior + case variables } enum PaywallMessageError: Error { @@ -221,6 +230,19 @@ enum PaywallMessage: Decodable, Equatable { self = .requestPermission(permissionType: permissionType, requestId: requestId) return } + case .requestCallback: + if let requestId = try? values.decode(String.self, forKey: .requestId), + let name = try? values.decode(String.self, forKey: .name), + let behavior = try? values.decode(CustomCallbackBehavior.self, forKey: .behavior) { + let variables = try? values.decode(JSON.self, forKey: .variables) + self = .requestCallback( + requestId: requestId, + name: name, + behavior: behavior, + variables: variables + ) + return + } } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index 88f81e1d4f..beb5944b37 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -31,6 +31,7 @@ final class PaywallMessageHandler: WebEventDelegate { private unowned let receiptManager: ReceiptManager private let factory: VariablesFactory private let permissionHandler: PermissionHandling + private let customCallbackRegistry: CustomCallbackRegistry struct EnqueuedMessage { let name: String @@ -42,11 +43,13 @@ final class PaywallMessageHandler: WebEventDelegate { init( receiptManager: ReceiptManager, factory: VariablesFactory, - permissionHandler: PermissionHandling + permissionHandler: PermissionHandling, + customCallbackRegistry: CustomCallbackRegistry ) { self.receiptManager = receiptManager self.factory = factory self.permissionHandler = permissionHandler + self.customCallbackRegistry = customCallbackRegistry } func handle(_ message: PaywallMessage) { @@ -201,6 +204,14 @@ final class PaywallMessageHandler: WebEventDelegate { requestId: requestId, paywall: paywall ) + case let .requestCallback(requestId, name, behavior, variables): + handleRequestCallback( + requestId: requestId, + name: name, + behavior: behavior, + variables: variables, + paywall: paywall + ) } } @@ -548,4 +559,94 @@ final class PaywallMessageHandler: WebEventDelegate { ) } } + + // MARK: - Custom Callback Handling + + private func handleRequestCallback( + requestId: String, + name: String, + behavior: CustomCallbackBehavior, + variables: JSON?, + paywall: Paywall + ) { + let paywallIdentifier = paywall.identifier + + // Emit the event for listeners + delegate?.eventDidOccur(.requestCallback( + name: name, + behavior: behavior, + requestId: requestId, + variables: variables + )) + + Task { + let callbackHandler = customCallbackRegistry.getHandler(paywallIdentifier: paywallIdentifier) + + guard let callbackHandler else { + // No handler registered, send failure result + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "No custom callback handler registered for paywall", + info: ["paywallIdentifier": paywallIdentifier, "callbackName": name] + ) + await sendCallbackResult( + requestId: requestId, + name: name, + status: .failure, + data: nil, + paywall: paywall + ) + return + } + + // Call the registered handler + let callback = CustomCallback(name: name, variables: variables?.dictionaryObject) + let result: CustomCallbackResult + do { + result = await callbackHandler(callback) + } catch { + Logger.debug( + logLevel: .error, + scope: .paywallViewController, + message: "Custom callback handler threw error", + info: ["callbackName": name], + error: error + ) + result = .failure() + } + + await sendCallbackResult( + requestId: requestId, + name: name, + status: result.status, + data: result.data, + paywall: paywall + ) + } + } + + nonisolated private func sendCallbackResult( + requestId: String, + name: String, + status: CustomCallbackResultStatus, + data: [String: Any]?, + paywall: Paywall + ) async { + var payload: [String: Any] = [ + "event_name": "callback_result", + "request_id": requestId, + "name": name, + "status": status.rawValue + ] + if let data { + payload["data"] = data + } + + guard let jsonData = try? JSONSerialization.data(withJSONObject: [payload]) else { + return + } + let base64Event = jsonData.base64EncodedString() + await passMessageToWebView(base64Event) + } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallWebEvent.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallWebEvent.swift index 6c5ac9df4c..f175abd947 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallWebEvent.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallWebEvent.swift @@ -21,4 +21,10 @@ enum PaywallWebEvent: Equatable { case customPlacement(name: String, params: JSON) case scheduleNotification(notification: LocalNotification) case userAttributesUpdated(attributes: JSON) + case requestCallback( + name: String, + behavior: CustomCallbackBehavior, + requestId: String, + variables: JSON? + ) } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6bb24562b9..e971ed0b4f 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1353,6 +1353,17 @@ extension Superwall: PaywallViewControllerEventDelegate { } } dependencyContainer.identityManager.mergeUserAttributesAndNotify(attributesDict) + case let .requestCallback(name, behavior, requestId, _): + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Custom callback requested", + info: [ + "name": name, + "behavior": behavior.rawValue, + "requestId": requestId + ] + ) } } }