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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions Sources/SuperwallKit/Paywall/Presentation/CustomCallback.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +117 to +126
Copy link

Choose a reason for hiding this comment

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

CHANGELOG.md needs updating for this new API addition per the style guide (CLAUDE.md:97-98)

The guide states: "Update CHANGELOG.md for customer-facing changes: Include new API additions... Focus on what the change does for developers."

Suggest adding an entry like:

### Enhancements
- Added `onCustomCallback()` method to `PaywallPresentationHandler` for handling custom callback requests from paywalls

Context Used: Context from dashboard - CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Paywall/Presentation/PaywallPresentationHandler.swift
Line: 117:126

Comment:
CHANGELOG.md needs updating for this new API addition per the style guide (`CLAUDE.md:97-98`)

The guide states: "Update CHANGELOG.md for customer-facing changes: Include new API additions... Focus on what the change does for developers."

Suggest adding an entry like:
```markdown
### Enhancements
- Added `onCustomCallback()` method to `PaywallPresentationHandler` for handling custom callback requests from paywalls
```

**Context Used:** Context from `dashboard` - CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=ae0afcf7-0b55-482b-9764-29f361e46714))

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

}
}
11 changes: 11 additions & 0 deletions Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -123,6 +130,8 @@ enum PaywallMessage: Decodable, Equatable {
case delay
case permissionType
case requestId
case behavior
case variables
}

enum PaywallMessageError: Error {
Expand Down Expand Up @@ -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
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
private unowned let receiptManager: ReceiptManager
private let factory: VariablesFactory
private let permissionHandler: PermissionHandling
private let customCallbackRegistry: CustomCallbackRegistry

struct EnqueuedMessage {
let name: String
Expand All @@ -42,11 +43,13 @@
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) {
Expand Down Expand Up @@ -201,6 +204,14 @@
requestId: requestId,
paywall: paywall
)
case let .requestCallback(requestId, name, behavior, variables):
handleRequestCallback(
requestId: requestId,
name: name,
behavior: behavior,
variables: variables,
paywall: paywall
)
}
}

Expand Down Expand Up @@ -352,7 +363,7 @@
// block selection
let selectionString =
// swiftlint:disable:next line_length
"var css = '*{-webkit-touch-callout:none;-webkit-user-select:none} .w-webflow-badge { display: none !important; }'; "

Check warning on line 366 in Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Superfluous Disable Command Violation: SwiftLint rule 'line_length' did not trigger a violation in the disabled region; remove the disable command (superfluous_disable_command)
+ "var head = document.head || document.getElementsByTagName('head')[0]; "
+ "var style = document.createElement('style'); style.type = 'text/css'; "
+ "style.appendChild(document.createTextNode(css)); head.appendChild(style); "
Expand Down Expand Up @@ -548,4 +559,94 @@
)
}
}

// 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?
)
}
11 changes: 11 additions & 0 deletions Sources/SuperwallKit/Superwall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,7 @@
// MARK: - PaywallViewControllerDelegate
extension Superwall: PaywallViewControllerEventDelegate {
@MainActor
func eventDidOccur(

Check warning on line 1292 in Sources/SuperwallKit/Superwall.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Function Body Length Violation: Function body should span 60 lines or less excluding comments and whitespace: currently spans 70 lines (function_body_length)
_ paywallEvent: PaywallWebEvent,
on paywallViewController: PaywallViewController
) async {
Expand Down Expand Up @@ -1353,6 +1353,17 @@
}
}
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
]
)
}
}
}
Loading