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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
- Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`.
- Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund.
- Adds best-effort public IPv4 and IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP.
- Adds return links, `yourscheme://superwall/return` and `https://<subdomain>.superwall.link/app-link/superwall/return` (also accepted on `superwall.app`). To open your app from a checkout page without the Open in App alert, add `applinks:<subdomain>.superwall.link` to your app's associated domains. They bring the user back to your app from a Superwall web flow, such as a paywall's checkout page, and leave the paywall open. Unlike other deep links, they don't track `deepLink_open` and don't dismiss the presented paywall.
- Adds the `teleportOpen` event (`teleport_open`), tracked with the paywall's info when a paywall teleports the user to the web, such as to its checkout page.
- Adds the `teleportReturn` event (`teleport_return`), tracked instead of `deepLink_open` when a return link brings the user back to your app, with the presented paywall's info if there is one.
- A web purchase from a paywall's checkout page now finishes like an App Store purchase: once the code is redeemed, the paywall runs the purchase button's after-purchase actions, and it's dismissed (as `.purchased`) only when those actions close it. Redemption itself is unchanged, including `willRedeemLink`, `didRedeemLink` and the redemption events.

### Fixes

- Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading.
- Stops matching installs for attribution unless the Superwall MMP is turned on for your app.
- Fixes paywalls on first launch missing acquisition attributes in their audience filters: they now wait up to 2 seconds for install attribution to finish.
- Fixes a paywall reporting `.declined` instead of `.purchased` when it closed after a purchase through an after-purchase action list, such as one that sets state and then closes.
- Fixes the paywall's loading spinner staying up after a web checkout completed while an older pending checkout was still being polled.
- Fixes a web purchase on a paywall whose products carry different entitlements showing the restore failed alert.

## 4.17.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,46 @@ enum InternalSuperwallEvent {
}
}

struct TeleportOpen: TrackableSuperwallEvent {
let paywallInfo: PaywallInfo

var superwallEvent: SuperwallEvent {
return .teleportOpen(paywallInfo: paywallInfo)
}

var audienceFilterParams: [String: Any] {
return paywallInfo.audienceFilterParams()
}

func getSuperwallParameters() async -> [String: Any] {
// No URL: a checkout page's path carries its sealed teleport.
return await paywallInfo.placementParams()
}
}

struct TeleportReturn: TrackableSuperwallEvent {
/// The presented paywall's, if one is presented when the return link opens the app.
let paywallInfo: PaywallInfo?
/// Why the checkout page sent the user back: `purchased` or `closed`.
var reason: String?

var superwallEvent: SuperwallEvent {
return .teleportReturn(paywallInfo: paywallInfo)
}

var audienceFilterParams: [String: Any] {
return paywallInfo?.audienceFilterParams() ?? [:]
}

func getSuperwallParameters() async -> [String: Any] {
var params = await paywallInfo?.placementParams() ?? [:]
if let reason {
params["reason"] = reason
}
return params
}
}

enum ConfigCacheStatus: String {
case cached = "CACHED"
case notCached = "NOT_CACHED"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,14 @@ public enum SuperwallEvent {
/// When a Stripe checkout session fails.
case stripeCheckoutFail(paywallInfo: PaywallInfo)

/// When a paywall opens its checkout page in the browser.
case teleportOpen(paywallInfo: PaywallInfo)

/// When a return link brings the user back to the app from a checkout page.
///
/// `paywallInfo` is the presented paywall's, if one is presented.
case teleportReturn(paywallInfo: PaywallInfo?)

/// When the test mode modal is opened.
case testModeModalOpen

Expand Down Expand Up @@ -582,6 +590,10 @@ extension SuperwallEvent {
return .init(objcEvent: .stripeCheckoutComplete)
case .stripeCheckoutFail:
return .init(objcEvent: .stripeCheckoutFail)
case .teleportOpen:
return .init(objcEvent: .teleportOpen)
case .teleportReturn:
return .init(objcEvent: .teleportReturn)
case .testModeModalOpen:
return .init(objcEvent: .testModeModalOpen)
case .testModeModalClose:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,12 @@ public enum SuperwallEventObjc: Int, CaseIterable {
/// When a refund request started from the Customer Center completes.
case customerCenterRefundRequest

/// When a paywall opens its checkout page in the browser.
case teleportOpen

/// When a return link brings the user back to the app from a checkout page.
case teleportReturn

public init(event: SuperwallEvent) {
self = event.backingData.objcEvent
}
Expand Down Expand Up @@ -450,6 +456,10 @@ public enum SuperwallEventObjc: Int, CaseIterable {
return "customerCenter_refundRequest"
case .paywallPageView:
return "paywall_page_view"
case .teleportOpen:
return "teleport_open"
case .teleportReturn:
return "teleport_return"
}
}
}
90 changes: 89 additions & 1 deletion Sources/SuperwallKit/DeepLinkRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ final class DeepLinkRouter {
private unowned let configManager: ConfigManager
private static var pendingDeepLink: URL?

/// Tracks an event. Replaced in tests to observe what's tracked.
var trackEvent: (Trackable) async -> Void = { event in
_ = await Superwall.shared.track(event)
}

/// The presented paywall's info, if a paywall is presented. Replaced in tests.
var presentedPaywallInfo: () async -> PaywallInfo? = {
await Superwall.shared.paywallViewController?.info
}

init(
webEntitlementRedeemer: WebEntitlementRedeemer,
debugManager: DebugManager,
Expand All @@ -29,6 +39,14 @@ final class DeepLinkRouter {

@discardableResult
func route(url: URL) -> Bool {
// A return link only brings the app back from a Superwall web flow. The
// flow's paywall finishes the job itself, so the link must not be tracked
// as a deep link: `deepLink_open` dismisses the presented paywall.
if url.isSuperwallReturnLink {
handleReturnLink(url)
return true
}

// Check if the URL matches the expected web2app format
if let code = url.redeemableCode {
Task {
Expand All @@ -54,8 +72,9 @@ final class DeepLinkRouter {
deepLinkUrl = url
}

let trackEvent = self.trackEvent
Task {
await Superwall.shared.track(InternalSuperwallEvent.DeepLink(url: deepLinkUrl))
await trackEvent(InternalSuperwallEvent.DeepLink(url: deepLinkUrl))
}

// Check if this is a debug URL
Expand All @@ -80,6 +99,38 @@ final class DeepLinkRouter {
return false
}

private func handleReturnLink(_ url: URL) {
// The paywall that opened the checkout takes its waiting screen out of sight at once.
let userInfo = url.superwallReturnReason.map { ["reason": $0] }
if Thread.isMainThread {
NotificationCenter.default.post(
name: .superwallReturnLinkOpened,
object: nil,
userInfo: userInfo
)
} else {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .superwallReturnLinkOpened,
object: nil,
userInfo: userInfo
)
}
}
// Tracked as `teleport_return` instead, with the presented paywall's info.
let trackEvent = self.trackEvent
let presentedPaywallInfo = self.presentedPaywallInfo
Task {
let paywallInfo = await presentedPaywallInfo()
await trackEvent(
InternalSuperwallEvent.TeleportReturn(
paywallInfo: paywallInfo,
reason: url.superwallReturnReason
)
)
}
}

private func listenToConfig() {
configManager.configState
.subscribe(
Expand Down Expand Up @@ -134,6 +185,11 @@ final class DeepLinkRouter {
return true
}

// Return links from Superwall web flows
if url.isSuperwallReturnLink {
return true
}

// Debug/preview URLs
if DebugManager.outcomeForDeepLink(url: url) != nil {
return true
Expand Down Expand Up @@ -162,6 +218,38 @@ final class DeepLinkRouter {
}

extension URL {
/// Whether this link brings the user back to the app from a Superwall web
/// flow, such as a paywall's checkout page. Matches
/// `scheme://superwall/return` and
/// `https://<subdomain>.superwall.app/app-link/superwall/return`.
var isSuperwallReturnLink: Bool {
if scheme != "http",
scheme != "https",
host == "superwall",
path == "/return" {
return true
}

if let host,
host.hasSuffix(".superwall.link")
|| host.hasSuffix(".superwall.app")
|| host.hasSuffix(".superwallapp.dev"),
path == "/app-link/superwall/return" {
return true
}

return false
}

/// Why the web flow sent the user back, when it says: `purchased` after paying on the checkout
/// page, `closed` when they closed it instead.
var superwallReturnReason: String? {
URLComponents(url: self, resolvingAgainstBaseURL: false)?
.queryItems?
.first { $0.name == "reason" }?
.value
}

/// The web checkout code to redeem given a Superwall deep link format.
var redeemableCode: String? {
let urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ actor PaywallRequestManager {
for request: PaywallRequest
) async -> Paywall {
var paywall = paywall
paywall.presentationId = UUID().uuidString

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Warning

updatePaywall replaces the ID already used by loading events

F5 · Warning · correctness

On an uncached request, getRawPaywall assigns a presentation ID before tracking response loading, and product loading uses that paywall. The fresh-request path then calls updatePaywall and replaces the ID, so loading and presentation events from the same lifecycle no longer correlate.

Preserve the ID for the originating fresh request and allocate a new ID only for reused paywalls; test loading-to-presentation ID consistency.
🤖 Prompt to fix with an AI agent
In `Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift:114`: `updatePaywall` replaces the ID already used by loading events.

On an uncached request, `getRawPaywall` assigns a presentation ID before tracking response loading, and product loading uses that paywall. The fresh-request path then calls `updatePaywall` and replaces the ID, so loading and presentation events from the same lifecycle no longer correlate.

Suggested fix: Preserve the ID for the originating fresh request and allocate a new ID only for reused paywalls; test loading-to-presentation ID consistency.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

won't fix

The premise doesn't hold: presentationId is assigned in exactly one place, updatePaywall. getRawPaywall never sets one, so the response and product loading events for a fresh request carry no presentation ID rather than a different one. The ID is allocated once per request, after loading, so every event from presentation onwards shares it, which is the intent. Reused cached paywalls get a fresh ID per request for the same reason.

paywall.experiment = request.responseIdentifiers.experiment
paywall.presentationSourceType = request.presentationSourceType
if let featureGating = request.overrides.featureGatingBehavior {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ public class PaywallViewController: UIViewController, LoadingDelegate {
/// Tracks whether explicit stripe_checkout_abandon was already received for this checkout flow.
private var didReceiveStripeCheckoutAbandonMessage = false

/// What the shopper sees of a checkout page's waiting screen while leaving and coming back.
private var existingTeleportReturnCover: TeleportReturnCover?

/// The product bought from this presentation, App Store or web. A close that
/// follows a purchase, such as a `close` among the button's after-purchase
/// actions, finishes the presentation as purchased rather than declined.
private(set) var completedPurchaseProduct: StoreProduct?

/// Ensures Stripe checkout callbacks are forwarded to WebEntitlementRedeemer in order.
private let stripeCheckoutCoordinator = SerialTaskCoordinator()

Expand Down Expand Up @@ -863,6 +871,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate {
|| isBeingPresented {
return completion(false)
}
completedPurchaseProduct = nil
Comment thread
pullfrog[bot] marked this conversation as resolved.
Superwall.shared.presentationItems.window?.makeKeyAndVisible()

set(
Expand Down Expand Up @@ -1343,16 +1352,24 @@ extension PaywallViewController: PaywallMessageHandlerDelegate {

func handleStripeCheckoutComplete(
checkoutContextId: String,
productId: String
productId: String,
shouldDismiss: Bool?
) {
didReceiveStripeCheckoutAbandonMessage = true
loadingState = .manualLoading
closeSafari()

// A paywall that says whether to dismiss wants the purchase finished
// like an App Store purchase; one that doesn't gets the legacy restore.
let completion: StripeCheckoutCompletion = shouldDismiss.map {
.purchase(productId: productId, shouldDismiss: $0)
} ?? .restore

enqueueStripeCheckoutTask { paywall in
await paywall.webEntitlementRedeemer.handleStripeCheckoutComplete(
contextId: checkoutContextId,
productId: productId
productId: productId,
completion: completion
)
}
}
Expand Down Expand Up @@ -1409,7 +1426,30 @@ extension PaywallViewController: PaywallMessageHandlerDelegate {
present(safariVC, animated: true)
}

func presentSafariExternal(_ url: URL) {
func presentSafariExternal(_ url: URL, drawsWaitingScreen: Bool) {
teleportReturnCover.leave(drawingWaitingScreen: drawsWaitingScreen) { [weak self] in
self?.openExternally(url)
}
}

func startTeleportWatch(_ watch: TeleportReturnCover.Watch) {
teleportReturnCover.startWatching(watch)
}

func endTeleportWatch() {
existingTeleportReturnCover?.stopWatching()
}

private var teleportReturnCover: TeleportReturnCover {
if let existingTeleportReturnCover {
return existingTeleportReturnCover
}
let cover = TeleportReturnCover(view: view, webView: webView)
existingTeleportReturnCover = cover
return cover
}

private func openExternally(_ url: URL) {
guard let sharedApplication = UIApplication.sharedApplication else {
return
}
Expand Down Expand Up @@ -1650,10 +1690,15 @@ extension PaywallViewController {

override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
guard isPresented else {
existingTeleportReturnCover?.reset()
if isSafariVCPresented {
return
}
if isSafariVCPresented {
// A purchase belongs to the presentation it was made in. The view controller is reused
// for the next one, whether the SDK or the app presents it, and `dismiss` has already
// turned this presentation's close into its result.
completedPurchaseProduct = nil
guard isPresented else {
return
}
Task {
Expand All @@ -1675,11 +1720,28 @@ extension PaywallViewController {
presentationDidFinishPrepare = false
}

/// Records a completed purchase before the paywall hears `transaction_complete`.
func markPurchaseCompleted(_ product: StoreProduct) {
completedPurchaseProduct = product
}

/// The result a dismissal reports: a decline after a completed purchase is a purchase.
func resolvedDismissal(
result: PaywallResult,
closeReason: PaywallCloseReason
) -> (result: PaywallResult, closeReason: PaywallCloseReason) {
if result == .declined, let product = completedPurchaseProduct {
return (.purchased(product), .systemLogic)
}
return (result, closeReason)
}

func dismiss(
result: PaywallResult,
closeReason: PaywallCloseReason,
completion: (() -> Void)? = nil
) {
let (result, closeReason) = resolvedDismissal(result: result, closeReason: closeReason)
dismissCompletionBlock = completion
paywallResult = result
paywall.closeReason = closeReason
Expand Down
Loading
Loading