From bbf7d0c9a28f76c2624a45458b0986bab118ba6f Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 29 Sep 2026 18:47:10 -0400 Subject: [PATCH 01/13] feat: complete checkout page web purchases like App Store purchases and handle the superwall/return link --- CHANGELOG.md | 3 + Sources/SuperwallKit/DeepLinkRouter.swift | 33 ++ .../PaywallViewController.swift | 35 +- .../Message Handling/PaywallMessage.swift | 7 +- .../PaywallMessageHandler.swift | 8 +- .../Transactions/TransactionManager.swift | 1 + .../Web/WebEntitlementRedeemer.swift | 86 ++++- .../DeepLink/DeepLinkRouterTests.swift | 43 +++ .../PaywallMessageHandlerDelegateMock.swift | 5 +- .../PaywallMessageHandlerTests.swift | 35 +- .../Web/WebEntitlementRedeemerTests.swift | 316 ++++++++++++++++++ 11 files changed, 550 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd797c7f89..59455cdf01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,13 @@ 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`. Requires iOS 15+. - 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 return links, `yourscheme://superwall/return` and `https://.superwall.app/app-link/superwall/return`. 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. +- 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. +- 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. ## 4.17.0 ### Enhancements diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index 8564bc1cce..d4afc80ead 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -29,6 +29,13 @@ 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 { + return true + } + // Check if the URL matches the expected web2app format if let code = url.redeemableCode { Task { @@ -134,6 +141,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 @@ -162,6 +174,27 @@ 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://.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.app") || host.hasSuffix(".superwallapp.dev"), + path == "/app-link/superwall/return" { + return true + } + + return false + } + /// The web checkout code to redeem given a Superwall deep link format. var redeemableCode: String? { let urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index df6182126c..a7eb18dc79 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -159,6 +159,11 @@ public class PaywallViewController: UIViewController, LoadingDelegate { /// Tracks whether explicit stripe_checkout_abandon was already received for this checkout flow. private var didReceiveStripeCheckoutAbandonMessage = false + /// 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() @@ -863,6 +868,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { || isBeingPresented { return completion(false) } + completedPurchaseProduct = nil Superwall.shared.presentationItems.window?.makeKeyAndVisible() set( @@ -1345,16 +1351,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 ) } } @@ -1685,11 +1699,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 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 fe946a2dde..d4b76d0df4 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 @@ -60,7 +60,8 @@ enum PaywallMessage: Decodable, Equatable { case stripeCheckoutStart(checkoutContextId: String, productId: String) case stripeCheckoutComplete( checkoutContextId: String, - productId: String + productId: String, + shouldDismiss: Bool? ) case stripeCheckoutSubmit(checkoutContextId: String, productId: String) case stripeCheckoutFail(checkoutContextId: String, productId: String) @@ -235,9 +236,11 @@ enum PaywallMessage: Decodable, Equatable { case .stripeCheckoutComplete: if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), let productId = try? values.decode(String.self, forKey: .productId) { + let shouldDismiss = (try? values.decodeIfPresent(Bool.self, forKey: .shouldDismiss)) ?? nil self = .stripeCheckoutComplete( checkoutContextId: checkoutContextId, - productId: productId + productId: productId, + shouldDismiss: shouldDismiss ) 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 ec15c5d491..f8d92b9955 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 @@ -26,7 +26,8 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) func handleStripeCheckoutComplete( checkoutContextId: String, - productId: String + productId: String, + shouldDismiss: Bool? ) func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) func revealWebViewBehindSpinner() @@ -206,14 +207,15 @@ final class PaywallMessageHandler: WebEventDelegate { state: .start, productId: productId ) - case let .stripeCheckoutComplete(checkoutContextId, productId): + case let .stripeCheckoutComplete(checkoutContextId, productId, shouldDismiss): trackStripeCheckoutEvent( state: .complete, productId: productId ) delegate?.handleStripeCheckoutComplete( checkoutContextId: checkoutContextId, - productId: productId + productId: productId, + shouldDismiss: shouldDismiss ) case let .stripeCheckoutSubmit(checkoutContextId, productId): trackStripeCheckoutEvent( diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index fb634ba103..5bd2681fb9 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -1141,6 +1141,7 @@ final class TransactionManager { case .internal(_, let paywallViewController, _): paywallInfo = await paywallViewController.info eventSource = .internal + await paywallViewController.markPurchaseCompleted(product) await paywallViewController.webView.messageHandler .handle( .transactionComplete( diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index c101f00df3..cb13e4ae55 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -9,6 +9,17 @@ import Foundation import UIKit +/// How a web checkout the paywall reported as complete finishes once its code is redeemed. +enum StripeCheckoutCompletion: Equatable { + /// Legacy: the paywall hears `restore_complete`, and the paywall dismisses + /// as `.restored` once its entitlements are active. + case restore + /// Like an App Store purchase: the paywall hears `transaction_complete`, so + /// the button's after-purchase actions run, and it dismisses as + /// `.purchased` only when `shouldDismiss` is true. + case purchase(productId: String, shouldDismiss: Bool) +} + actor WebEntitlementRedeemer { private let network: Network private let storage: Storage @@ -208,7 +219,8 @@ actor WebEntitlementRedeemer { func handleStripeCheckoutComplete( contextId: String, - productId: String + productId: String, + completion: StripeCheckoutCompletion = .restore ) async { awaitingCheckoutComplete = false @@ -233,7 +245,8 @@ actor WebEntitlementRedeemer { let outcome = await pollStripeRedemptionResult( contextId: contextId, productId: productId, - trigger: .checkoutComplete + trigger: .checkoutComplete, + completion: completion ) // Don't hide spinner if another poll is in-flight — it will handle the @@ -428,7 +441,8 @@ actor WebEntitlementRedeemer { response: RedeemResponse, type: RedeemType, superwall: Superwall, - callbackMode: RedemptionCallbackMode + callbackMode: RedemptionCallbackMode, + completion: StripeCheckoutCompletion = .restore ) async { storage.save(Date(), forType: LastWebEntitlementsFetchDate.self) @@ -462,7 +476,8 @@ actor WebEntitlementRedeemer { allEntitlementIds: Set(allEntitlements.map { $0.id }), paywallEntitlementIds: paywallEntitlementIds, superwall: superwall, - callbackMode: callbackMode + callbackMode: callbackMode, + completion: completion ) } } @@ -554,7 +569,8 @@ actor WebEntitlementRedeemer { allEntitlementIds: Set, paywallEntitlementIds: Set, superwall: Superwall, - callbackMode: RedemptionCallbackMode + callbackMode: RedemptionCallbackMode, + completion: StripeCheckoutCompletion ) async { guard let codeResult = response.results.first(where: { $0.code == code }) else { return } @@ -596,11 +612,25 @@ actor WebEntitlementRedeemer { } } - if let paywallVc = superwall.paywallViewController, - !paywallEntitlementIds.isEmpty, - paywallEntitlementIds.subtracting(allEntitlementIds).isEmpty, - superwallOptions.paywalls.automaticallyDismiss { - await superwall.dismiss(paywallVc, result: .restored) + switch completion { + case .restore: + if let paywallVc = superwall.paywallViewController, + !paywallEntitlementIds.isEmpty, + paywallEntitlementIds.subtracting(allEntitlementIds).isEmpty, + superwallOptions.paywalls.automaticallyDismiss { + await superwall.dismiss(paywallVc, result: .restored) + } + case let .purchase(productId, shouldDismiss): + if case .success(_, let redemptionInfo) = codeResult, + let paywallVc = superwall.paywallViewController { + await finishWebPurchase( + productId: productId, + trialPeriodDays: redemptionInfo.paywallInfo?.product?.trialPeriodDays ?? 0, + shouldDismiss: shouldDismiss && superwallOptions.paywalls.automaticallyDismiss, + paywallViewController: paywallVc, + superwall: superwall + ) + } } await MainActor.run { @@ -660,6 +690,36 @@ actor WebEntitlementRedeemer { } } + /// Finishes a redeemed web purchase the way `TransactionManager` finishes + /// an App Store purchase made from a paywall. + private func finishWebPurchase( + productId: String, + trialPeriodDays: Int, + shouldDismiss: Bool, + paywallViewController: PaywallViewController, + superwall: Superwall + ) async { + let product = StoreProduct.blank(productIdentifier: productId) + let paywallInfo = await paywallViewController.info + let didStartFreeTrial = trialPeriodDays > 0 && paywallInfo.isFreeTrialAvailable + let trialEndDate = didStartFreeTrial + ? Calendar.current.date(byAdding: .day, value: trialPeriodDays, to: Date()) + : nil + + await paywallViewController.markPurchaseCompleted(product) + await paywallViewController.webView.messageHandler.handle( + .transactionComplete( + trialEndDate: trialEndDate, + productIdentifier: productId, + didStartFreeTrial: didStartFreeTrial + ) + ) + + if shouldDismiss { + await superwall.dismiss(paywallViewController, result: .purchased(product)) + } + } + @MainActor private func presentAlertOnTopViewController( title: String, @@ -770,7 +830,8 @@ actor WebEntitlementRedeemer { private func pollStripeRedemptionResult( contextId: String, productId: String, - trigger: StripePollTrigger + trigger: StripePollTrigger, + completion: StripeCheckoutCompletion = .restore ) async -> StripePollOutcome { if hasActiveStripePoll { return .skippedInFlight @@ -803,7 +864,8 @@ actor WebEntitlementRedeemer { response: response, type: .code(code), superwall: superwall, - callbackMode: .pollFakeCompatibility + callbackMode: .pollFakeCompatibility, + completion: completion ) finalOutcome = .redeemed return finalOutcome diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 1c4401500a..281f6c349c 100644 --- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift +++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift @@ -180,4 +180,47 @@ struct DeepLinkRouterTests { let url = URL(string: "myapp://home")! #expect(url.redeemableCode == nil) } + + // MARK: - Return Links + + @Test( + "isSuperwallReturnLink matches the scheme and universal link forms", + arguments: [ + "myapp://superwall/return", + "myapp://superwall/return?source=checkout", + "https://myapp.superwall.app/app-link/superwall/return", + "https://myapp.superwallapp.dev/app-link/superwall/return", + ] + ) + func isSuperwallReturnLink_matches(link: String) { + #expect(URL(string: link)!.isSuperwallReturnLink == true) + } + + @Test( + "isSuperwallReturnLink ignores other links", + arguments: [ + "myapp://superwall/redeem?code=ABC123", + "myapp://superwall/checkout-return", + "myapp://superwall/return/extra", + "myapp://return", + "myapp://other/return", + "https://superwall/return", + "https://example.com/app-link/superwall/return", + "https://myapp.superwall.app/superwall/return", + "https://myapp.superwall.app/app-link/superwall/other", + ] + ) + func isSuperwallReturnLink_ignores(link: String) { + #expect(URL(string: link)!.isSuperwallReturnLink == false) + } + + @Test("Returns true for a return link so a cold start still claims it") + func storeDeepLink_returnLink() { + #expect(DeepLinkRouter.storeDeepLink(URL(string: "myapp://superwall/return")!) == true) + #expect( + DeepLinkRouter.storeDeepLink( + URL(string: "https://myapp.superwall.app/app-link/superwall/return")! + ) == true + ) + } } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift index 23a247015b..8bed19305d 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift @@ -49,6 +49,7 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { var didOpenPaymentSheet = false var stripeCheckoutSubmit: (checkoutContextId: String, productId: String)? var stripeCheckoutComplete: (checkoutContextId: String, productId: String)? + var stripeCheckoutCompleteShouldDismiss: Bool? var stripeCheckoutAbandon: (checkoutContextId: String, productId: String)? var request: PresentationRequest? @@ -102,9 +103,11 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { func handleStripeCheckoutComplete( checkoutContextId: String, - productId: String + productId: String, + shouldDismiss: Bool? ) { stripeCheckoutComplete = (checkoutContextId, productId) + stripeCheckoutCompleteShouldDismiss = shouldDismiss } func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) { diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index eb1a94281c..ba0294ad62 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -721,7 +721,36 @@ struct PaywallMessageHandlerTests { #expect(message == .stripeCheckoutComplete( checkoutContextId: "ctx_123", - productId: "prod_123" + productId: "prod_123", + shouldDismiss: nil + )) + } + + @Test(arguments: [true, false]) + func decodeStripeCheckoutComplete_withShouldDismiss(shouldDismiss: Bool) throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "stripe_checkout_complete", + "checkout_context_id": "ctx_123", + "product_identifier": "prod_123", + "should_dismiss": \(shouldDismiss) + } + ] + } + } + """ + + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages.first == .stripeCheckoutComplete( + checkoutContextId: "ctx_123", + productId: "prod_123", + shouldDismiss: shouldDismiss )) } @@ -821,12 +850,14 @@ struct PaywallMessageHandlerTests { messageHandler.handle( .stripeCheckoutComplete( checkoutContextId: "ctx_123", - productId: "prod_123" + productId: "prod_123", + shouldDismiss: false ) ) #expect(delegate.stripeCheckoutComplete?.checkoutContextId == "ctx_123") #expect(delegate.stripeCheckoutComplete?.productId == "prod_123") + #expect(delegate.stripeCheckoutCompleteShouldDismiss == false) } @Test diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 4224cea3a1..af6e1460f2 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -3024,4 +3024,320 @@ struct WebEntitlementRedeemerTests { } #expect(mockNetwork.pollRedemptionResultCallCount == 1) } + + // MARK: - Web purchases finished like App Store purchases + + private struct WebPurchaseHarness { + let redeemer: WebEntitlementRedeemer + let paywallVc: PaywallViewControllerMock + let finishes: PaywallFinishRecorder + let webView: FakeWebView + let delegate: MockSuperwallDelegate + let network: NetworkMock + } + + private func makeWebPurchaseHarness( + result: RedemptionResult? = nil, + automaticallyDismiss: Bool = true + ) async -> WebPurchaseHarness { + let cache = dependencyContainer.paywallManager.cache + let messageHandler = await PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = await FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let paywallVc = await PaywallViewControllerMock( + paywall: .stub(), + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: webView, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: cache, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + cache.save(paywallVc, forKey: "web-purchase") + cache.activePaywallVcKey = "web-purchase" + let finishes = await PaywallFinishRecorder() + await MainActor.run { + messageHandler.delegate = paywallVc + paywallVc.delegate = PaywallViewControllerDelegateAdapter( + swiftDelegate: finishes, + objcDelegate: nil + ) + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + options.paywalls.automaticallyDismiss = automaticallyDismiss + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let entitlements: Set = [.stub()] + let redemption = result ?? RedemptionResult.success( + code: "web_purchase_code", + redemptionInfo: .init( + ownership: .appUser(appUserId: "appUserId"), + purchaserInfo: .init( + appUserId: "appUserId", + email: nil, + storeIdentifiers: .stripe(customerId: "cus_123", subscriptionIds: ["sub_123"]) + ), + entitlements: entitlements + ) + ) + mockNetwork.pollRedemptionResultResponses = [ + .success( + RedeemResponse( + results: [redemption], + customerInfo: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: Array(entitlements) + ) + ) + ) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: StorageMock(internalRedeemResponse: nil), + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + return WebPurchaseHarness( + redeemer: redeemer, + paywallVc: paywallVc, + finishes: finishes, + webView: webView, + delegate: mockDelegate, + network: mockNetwork + ) + } + + /// The event names the paywall's webview was sent. + @MainActor + private func sentEventNames(_ webView: FakeWebView) -> [String] { + webView.evaluatedScripts.flatMap { script -> [String] in + guard let start = script.range(of: "accept64('"), + let end = script.range(of: "')", range: start.upperBound.. Bool { + for _ in 0..<40 { + if await sentEventNames(webView).contains(name) { + return true + } + try? await Task.sleep(nanoseconds: 25_000_000) + } + return false + } + + @Test("A web purchase that should dismiss tells the paywall transaction_complete and dismisses as purchased") + func testStripeCheckoutComplete_purchase_dismisses() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness() + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: true) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + let results = await harness.finishes.waitForResults() + #expect(results.count == 1) + if case let .purchased(product) = results.first { + #expect(product.productIdentifier == "stripe|price_1") + } else { + Issue.record("Expected a purchased result, got \(String(describing: results.first))") + } + #expect(await harness.paywallVc.completedPurchaseProduct?.productIdentifier == "stripe|price_1") + } + + @Test("A web purchase that should not dismiss keeps the paywall so its actions run") + func testStripeCheckoutComplete_purchase_keepsPaywall() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness() + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: false) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + #expect(await harness.finishes.waitForResults().isEmpty) + #expect(await harness.paywallVc.loadingState == .ready) + } + + @Test("A web purchase never dismisses when automatic dismissal is off") + func testStripeCheckoutComplete_purchase_respectsAutomaticallyDismiss() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness(automaticallyDismiss: false) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: true) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + #expect(await harness.finishes.waitForResults().isEmpty) + } + + @Test("A web purchase tracks the same events and redeem callbacks as the legacy completion") + func testStripeCheckoutComplete_purchase_keepsRedemptionContract() async { + guard #available(iOS 14.0, *) else { + return + } + let legacy = await makeWebPurchaseHarness() + await legacy.redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "stripe|price_1") + let legacyEvents = Set(legacy.delegate.eventsReceived.map { $0.backingData.objcEvent }) + + let purchase = await makeWebPurchaseHarness() + await purchase.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: false) + ) + let purchaseEvents = Set(purchase.delegate.eventsReceived.map { $0.backingData.objcEvent }) + + #expect(purchase.delegate.willRedeemCallCount == 1) + #expect(purchase.delegate.receivedResult?.code == "web_purchase_code") + #expect(purchaseEvents.contains(SuperwallEventObjc.redemptionComplete)) + #expect(purchaseEvents == legacyEvents) + } + + @Test("A legacy web checkout completion does not send transaction_complete") + func testStripeCheckoutComplete_restore_unchanged() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness() + + await harness.redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") + + #expect(harness.network.pollRedemptionResultCallCount == 1) + #expect(await waitForEvent("transaction_complete", in: harness.webView) == false) + #expect(await harness.paywallVc.completedPurchaseProduct == nil) + } + + @Test("A failed redemption finishes nothing, even for a web purchase") + func testStripeCheckoutComplete_purchase_failedRedemption() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness( + result: .invalidCode(code: "web_purchase_code") + ) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: true) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView) == false) + #expect(await harness.finishes.waitForResults().isEmpty) + } + + @Test("A close after a purchase finishes the paywall as purchased") + @MainActor + func testResolvedDismissal_afterPurchase() { + let paywallVc = PaywallViewController( + paywall: .stub(), + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: SWWebView( + isMac: false, + messageHandler: PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ), + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ), + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: nil, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + + let before = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) + #expect(before.result == .declined) + #expect(before.closeReason == .manualClose) + + paywallVc.markPurchaseCompleted(StoreProduct.blank(productIdentifier: "stripe|price_1")) + + let after = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) + if case let .purchased(product) = after.result { + #expect(product.productIdentifier == "stripe|price_1") + } else { + Issue.record("Expected purchased, got \(after.result)") + } + #expect(after.closeReason == .systemLogic) + + let restored = paywallVc.resolvedDismissal(result: .restored, closeReason: .systemLogic) + #expect(restored.result == .restored) + } +} + +/// Records how a paywall finished. +@MainActor +final class PaywallFinishRecorder: PaywallViewControllerDelegate { + private(set) var results: [PaywallResult] = [] + + func paywall( + _ paywall: PaywallViewController, + didFinishWith result: PaywallResult, + shouldDismiss: Bool + ) { + results.append(result) + } + + /// Gives an asynchronous dismissal time to land. + func waitForResults() async -> [PaywallResult] { + for _ in 0..<20 where results.isEmpty { + try? await Task.sleep(nanoseconds: 25_000_000) + } + return results + } } From 61aff7f9f4ba5a69e811c7cdd9410ead9ca13f3b Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 8 Oct 2026 16:23:52 +0200 Subject: [PATCH 02/13] feat: add teleport support --- CHANGELOG.md | 4 +- .../TrackableSuperwallEvent.swift | 40 ++ .../Superwall Placement/SuperwallEvent.swift | 12 + .../SuperwallEventObjc.swift | 10 + Sources/SuperwallKit/DeepLinkRouter.swift | 55 +- .../Request/PaywallRequestManager.swift | 1 + .../PaywallViewController.swift | 29 +- .../View Controller/TeleportReturnCover.swift | 515 ++++++++++++++++++ .../Message Handling/PaywallMessage.swift | 42 +- .../PaywallMessageHandler.swift | 67 ++- .../View Controller/Web View/SWWebView.swift | 13 +- .../SubscriptionsApiEnvironmentScript.swift | 29 + .../Web/WebEntitlementRedeemer.swift | 33 +- SuperwallKit.xcodeproj/project.pbxproj | 12 + .../Internal Tracking/TrackTests.swift | 52 ++ .../DeepLink/DeepLinkRouterTests.swift | 98 ++++ .../PaywallMessageHandlerDelegateMock.swift | 13 +- .../PaywallMessageHandlerTests.swift | 183 +++++++ ...bscriptionsApiEnvironmentScriptTests.swift | 32 ++ 19 files changed, 1220 insertions(+), 20 deletions(-) create mode 100644 Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift create mode 100644 Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift create mode 100644 Tests/SuperwallKitTests/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScriptTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 59455cdf01..51dc101cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ 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`. Requires iOS 15+. - 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 return links, `yourscheme://superwall/return` and `https://.superwall.app/app-link/superwall/return`. 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 return links, `yourscheme://superwall/return` and `https://.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:.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 diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index e7940d3ace..838ca539a1 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -997,6 +997,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" diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index 81d025c606..e6ad6d63a4 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -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 @@ -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: diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift index f371ba0450..a8ba6cf707 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -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 } @@ -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" } } } diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index d4afc80ead..94ee84c974 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -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, @@ -33,6 +43,35 @@ final class DeepLinkRouter { // 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 { + // 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 + ) + ) + } return true } @@ -61,8 +100,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 @@ -187,7 +227,9 @@ extension URL { } if let host, - host.hasSuffix(".superwall.app") || host.hasSuffix(".superwallapp.dev"), + host.hasSuffix(".superwall.link") + || host.hasSuffix(".superwall.app") + || host.hasSuffix(".superwallapp.dev"), path == "/app-link/superwall/return" { return true } @@ -195,6 +237,15 @@ extension URL { 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) diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift index c177f12e67..2d4cade1aa 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift @@ -111,6 +111,7 @@ actor PaywallRequestManager { for request: PaywallRequest ) async -> Paywall { var paywall = paywall + paywall.presentationId = UUID().uuidString paywall.experiment = request.responseIdentifiers.experiment paywall.presentationSourceType = request.presentationSourceType if let featureGating = request.overrides.featureGatingBehavior { diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index a7eb18dc79..df9b5cd483 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -159,6 +159,9 @@ 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. @@ -1425,7 +1428,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 } @@ -1673,6 +1699,7 @@ extension PaywallViewController { override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + existingTeleportReturnCover?.reset() guard isPresented else { return } diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift new file mode 100644 index 0000000000..8493cc68dc --- /dev/null +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -0,0 +1,515 @@ +// +// TeleportReturnCover.swift +// SuperwallKit +// + +import UIKit +import WebKit + +extension Notification.Name { + /// A Superwall return link (`scheme://superwall/return`) opened the app. + static let superwallReturnLinkOpened = Notification.Name("SuperwallReturnLinkOpened") +} + +/// Decides what the shopper sees of a checkout page's waiting screen as they leave the app for +/// the browser and come back. +/// +/// The waiting screen has to be in place when they switch back by hand, so it must already be in +/// the snapshot iOS takes when the app leaves. But iOS also shows that snapshot while the app comes +/// back, however it comes back, and a shopper who returns through the return link, having paid or +/// left the checkout, must never see the screen. A webview paints nothing in the background, so: +/// - leaving, the page draws the screen under a still of the paywall, which comes off once the app +/// is in the background, so the app switcher shows the screen; +/// - in the background, where the page cannot run, the checkout is checked here. Once it is paid +/// or expired, a picture of the paywall without the screen covers it and iOS retakes the +/// snapshot; +/// - just before iOS suspends the app, nothing settled yet, it covers the screen the same way. A +/// return link can arrive at any time after that, and iOS can no longer be asked to retake the +/// snapshot then. The app switcher shows the paywall from then on; +/// - a return link covers it before the first frame, whatever else happened. +/// Back in the app, through the return link or settled, the page removes the screen and the cover +/// comes off. Back by hand with the checkout still open, the cover fades and shows the screen. +@MainActor +final class TeleportReturnCover: NSObject { + /// What the page hands over to check its checkout, while the waiting screen is up. + struct Watch: Decodable, Equatable { + let teleportId: String + let teleportStatusUrl: URL + let checkoutStatusUrl: URL + let publicApiKey: String + } + + /// Why the waiting screen is covered. + private enum Reason { + /// The checkout was paid or expired while the app was in the background. + case settled + /// The return link opened the app. + case returnLink + /// The app was about to be suspended. The checkout may still be open. + case suspending + } + + /// How long leaving waits for the page to draw the waiting screen. + private static let drawWait: UInt64 = 500_000_000 + /// How long the still stays up when the app does not leave (the browser did not open). + private static let stillTimeout: UInt64 = 2_000_000_000 + /// The longest the checkout is checked in the background. + private static let backgroundCheckLimit: TimeInterval = 25 + /// How much background time is kept to cover the screen and have iOS retake the snapshot. + private static let suspendLead: TimeInterval = 4 + private static let backgroundCheckInterval: UInt64 = 1_000_000_000 + /// How long the app keeps running once it asked iOS to retake the snapshot. + private static let snapshotLinger: UInt64 = 1_000_000_000 + /// How long a return by hand waits for a return link before showing the screen again. Some + /// apps hand the link over only after the app is active. + private static let revealGrace: UInt64 = 350_000_000 + private static let revealDuration: TimeInterval = 0.25 + + private weak var view: UIView? + private weak var webView: WKWebView? + private var leavingStill: UIView? + private var pendingOpen: (id: UUID, open: () -> Void)? + /// The paywall as it was before the waiting screen went up. + private var paywallPicture: UIImage? + private var watch: Watch? + private var returnCover: (view: UIView, reason: Reason)? + /// Why the return link brought the user back, as the page put it in the link. + private var returnReason: String? + private var uncovering: UUID? + private var revealing: UUID? + private var backgroundCheck: Task? + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + + init(view: UIView, webView: WKWebView) { + self.view = view + self.webView = webView + super.init() + let center = NotificationCenter.default + // Called synchronously: iOS takes the snapshot as soon as these observers return. + center.addObserver( + self, + selector: #selector(appDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(appWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(returnLinkOpened), + name: .superwallReturnLinkOpened, + object: nil + ) + } + + /// Has the page draw its waiting screen under a still of the paywall, then calls `open`. + func leave(drawingWaitingScreen: Bool, open: @escaping () -> Void) { + guard + drawingWaitingScreen, + #available(iOS 14.0, *), + let view, + let webView, + let still = view.snapshotView(afterScreenUpdates: false) + else { + open() + return + } + removeLeavingStill() + paywallPicture = UIGraphicsImageRenderer(bounds: view.bounds).image { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: false) + } + still.frame = view.bounds + still.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(still) + leavingStill = still + + let id = UUID() + pendingOpen = (id, open) + webView.callAsyncJavaScript( + "return await window.app?.showCheckoutWaitingScreen?.() ?? false", + arguments: [:], + in: nil, + in: .page + ) { [weak self] _ in + Task { @MainActor in + self?.open(id) + } + } + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: Self.drawWait) + self?.open(id) + } + } + + func startWatching(_ watch: Watch) { + self.watch = watch + } + + func stopWatching() { + watch = nil + stopBackgroundCheck() + } + + /// The paywall is gone: nothing of a checkout stays over it. + func reset() { + removeLeavingStill() + removeCover() + pendingOpen = nil + paywallPicture = nil + stopWatching() + } + + private func open(_ id: UUID) { + guard let pending = pendingOpen, pending.id == id else { + return + } + pendingOpen = nil + pending.open() + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: Self.stillTimeout) + guard let self, UIApplication.sharedApplication?.applicationState == .active else { + return + } + self.removeLeavingStill() + } + } + + private func removeLeavingStill() { + leavingStill?.removeFromSuperview() + leavingStill = nil + } + + // MARK: - App lifecycle + + @objc private func appDidEnterBackground() { + if leavingStill != nil { + removeLeavingStill() + // Commit now, so the snapshot shows the waiting screen rather than the still. + CATransaction.flush() + } + if watch != nil, paywallPicture != nil, returnCover == nil { + checkInBackground() + } + } + + @objc private func appWillEnterForeground() { + stopBackgroundCheck() + } + + @objc private func appDidBecomeActive() { + guard let returnCover else { + return + } + switch returnCover.reason { + case .settled, + .returnLink: + uncover() + case .suspending: + revealAfterGrace() + } + } + + @objc private func returnLinkOpened(_ notification: Notification) { + returnReason = notification.userInfo?["reason"] as? String + guard watch != nil || returnCover != nil else { + // No waiting screen to cover, but the page still has to know the shopper is back: it + // checks the checkout for a while, then treats an unpaid one as abandoned. + tellPageOfReturn() + return + } + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Checkout: return link opened, covering the waiting screen" + ) + if let returnCover { + self.returnCover = (returnCover.view, .returnLink) + } else { + cover(.returnLink) + } + guard returnCover != nil else { + // No picture of the paywall to cover with (the page reloaded since it left). + tellPageOfReturn() + return + } + if UIApplication.sharedApplication?.applicationState == .active { + uncover() + } + } + + // MARK: - Background check + + private func checkInBackground() { + stopBackgroundCheck() + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Checkout: checking the checkout while the app is in the background" + ) + guard let application = UIApplication.sharedApplication else { + return + } + backgroundTask = application.beginBackgroundTask(withName: "Superwall checkout") { [weak self] in + // Out of time before the check finished: cover the screen while iOS still listens. + Task { @MainActor in + guard let self else { + return + } + self.coverBeforeSuspending() + self.stopBackgroundCheck() + } + } + // Leaves enough of the background time to cover the screen and have the snapshot retaken. + let remaining = application.backgroundTimeRemaining + let checkFor = min(Self.backgroundCheckLimit, max(0, remaining - Self.suspendLead)) + backgroundCheck = Task { @MainActor [weak self] in + let deadline = Date().addingTimeInterval(checkFor) + while !Task.isCancelled, Date() < deadline { + guard let watch = self?.watch else { + break + } + if await CheckoutStatusCheck.isSettled(watch) { + guard !Task.isCancelled else { + break + } + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Checkout: settled in the background, covering the waiting screen" + ) + self?.cover(.settled) + self?.refreshSnapshot() + try? await Task.sleep(nanoseconds: Self.snapshotLinger) + self?.endBackgroundTask() + return + } + try? await Task.sleep(nanoseconds: Self.backgroundCheckInterval) + } + guard !Task.isCancelled else { + return + } + self?.coverBeforeSuspending() + try? await Task.sleep(nanoseconds: Self.snapshotLinger) + self?.endBackgroundTask() + } + } + + /// iOS is about to suspend the app, and a return link that arrives after that would come back + /// to a snapshot with the waiting screen in it. So the snapshot goes back to the paywall. + private func coverBeforeSuspending() { + guard watch != nil, returnCover == nil else { + return + } + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Checkout: suspending with the checkout open, covering the waiting screen" + ) + cover(.suspending) + refreshSnapshot() + } + + private func stopBackgroundCheck() { + backgroundCheck?.cancel() + backgroundCheck = nil + endBackgroundTask() + } + + private func endBackgroundTask() { + guard backgroundTask != .invalid else { + return + } + UIApplication.sharedApplication?.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } + + // MARK: - Cover + + private func cover(_ reason: Reason) { + guard returnCover == nil, let view, let paywallPicture else { + return + } + let cover = UIImageView(image: paywallPicture) + cover.frame = view.bounds + cover.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(cover) + returnCover = (cover, reason) + CATransaction.flush() + } + + private func removeCover() { + returnCover?.view.removeFromSuperview() + returnCover = nil + returnReason = nil + uncovering = nil + revealing = nil + } + + private func refreshSnapshot() { + guard let session = view?.window?.windowScene?.session else { + return + } + UIApplication.sharedApplication?.requestSceneSessionRefresh(session) + } + + /// Has the page remove the waiting screen, then takes the cover off once that is painted. + private func uncover() { + guard returnCover != nil, uncovering == nil else { + return + } + revealing = nil + let id = UUID() + uncovering = id + if #available(iOS 14.0, *), let webView { + webView.callAsyncJavaScript( + "return await window.app?.hideCheckoutWaitingScreen?.({ reason }) ?? false", + arguments: ["reason": returnReason ?? ""], + in: nil, + in: .page + ) { [weak self] _ in + Task { @MainActor in + self?.finishUncovering(id) + } + } + } + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: Self.drawWait) + self?.finishUncovering(id) + } + } + + private func finishUncovering(_ id: UUID) { + guard uncovering == id else { + return + } + removeCover() + Logger.debug( + logLevel: .debug, + scope: .paywallViewController, + message: "Checkout: waiting screen removed, cover off" + ) + } +} + +// MARK: - Coming back + +private extension TeleportReturnCover { + func tellPageOfReturn() { + guard #available(iOS 14.0, *), let webView else { + return + } + webView.callAsyncJavaScript( + "return await window.app?.hideCheckoutWaitingScreen?.({ reason }) ?? false", + arguments: ["reason": returnReason ?? ""], + in: nil, + in: .page, + completionHandler: nil + ) + returnReason = nil + } + + /// Back by hand with the checkout still open: the waiting screen is still the right thing to + /// show, so the cover fades off it, unless a return link turns up first. + func revealAfterGrace() { + guard revealing == nil, uncovering == nil else { + return + } + let id = UUID() + revealing = id + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: Self.revealGrace) + guard + let self, + self.revealing == id, + let returnCover = self.returnCover, + returnCover.reason == .suspending, + UIApplication.sharedApplication?.applicationState == .active + else { + return + } + self.revealing = nil + // The cover is the paywall, so this reads as the waiting screen coming up over it. + UIView.animate( + withDuration: Self.revealDuration, + delay: 0, + options: [.curveEaseOut, .allowUserInteraction] + ) { + returnCover.view.alpha = 0 + } completion: { [weak self] _ in + guard let self, self.returnCover?.view === returnCover.view else { + return + } + self.removeCover() + } + } + } +} + +/// Reads a checkout's status from the background, where the paywall page cannot run. +enum CheckoutStatusCheck { + /// Paid or expired: the waiting screen goes, and the shopper likely comes back through the + /// return link. + static func isSettled(_ watch: TeleportReturnCover.Watch) async -> Bool { + guard let teleport = await post( + watch.teleportStatusUrl, + body: ["teleportId": watch.teleportId], + publicApiKey: watch.publicApiKey + ) else { + return false + } + switch teleport["status"] as? String { + case "completed", + "expired": + return true + default: + break + } + guard + let checkoutId = teleport["checkoutContextId"] as? String, + let checkout = await post( + watch.checkoutStatusUrl, + body: ["checkoutId": checkoutId], + publicApiKey: watch.publicApiKey + ) + else { + return false + } + return checkout["status"] as? String == "completed" + } + + private static func post( + _ url: URL, + body: [String: String], + publicApiKey: String + ) async -> [String: Any]? { + var request = URLRequest(url: url, timeoutInterval: 5) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(publicApiKey)", forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + return await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { data, response, _ in + guard + let data, + (response as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: json) + } + .resume() + } + } +} 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 d4b76d0df4..14704d4db2 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 @@ -49,7 +49,13 @@ enum PaywallMessage: Decodable, Equatable { case close case restore case openUrl(_ url: URL) - case openUrlInSafari(_ url: URL) + case openUrlInSafari( + _ url: URL, + drawsWaitingScreen: Bool = false, + isTeleport: Bool = false + ) + case teleportWatchStart(TeleportReturnCover.Watch) + case teleportWatchEnd case openPaymentSheet(_ url: URL) case openDeepLink(url: URL) case purchase(productId: String, shouldDismiss: Bool) @@ -107,6 +113,8 @@ enum PaywallMessage: Decodable, Equatable { case restore case openUrl = "open_url" case openUrlInSafari = "open_url_external" + case teleportWatchStart = "teleport_watch_start" + case teleportWatchEnd = "teleport_watch_end" case openDeepLink = "open_deep_link" case purchase case custom @@ -141,6 +149,12 @@ enum PaywallMessage: Decodable, Equatable { case attributes case reviewType case browserType + case waitingScreen + case teleport + case teleportId + case teleportStatusUrl + case checkoutStatusUrl + case publicApiKey case checkoutContextId case type case title @@ -194,9 +208,33 @@ enum PaywallMessage: Decodable, Equatable { case .openUrlInSafari: if let urlString = try? values.decode(String.self, forKey: .url), let url = URL(string: urlString) { - self = .openUrlInSafari(url) + let waitingScreen = (try? values.decode(Bool.self, forKey: .waitingScreen)) ?? false + let teleport = (try? values.decode(Bool.self, forKey: .teleport)) ?? false + self = .openUrlInSafari( + url, + drawsWaitingScreen: waitingScreen, + isTeleport: teleport + ) + return + } + case .teleportWatchStart: + if let teleportId = try? values.decode(String.self, forKey: .teleportId), + let teleportStatusUrl = try? values.decode(URL.self, forKey: .teleportStatusUrl), + let checkoutStatusUrl = try? values.decode(URL.self, forKey: .checkoutStatusUrl), + let publicApiKey = try? values.decode(String.self, forKey: .publicApiKey) { + self = .teleportWatchStart( + TeleportReturnCover.Watch( + teleportId: teleportId, + teleportStatusUrl: teleportStatusUrl, + checkoutStatusUrl: checkoutStatusUrl, + publicApiKey: publicApiKey + ) + ) return } + case .teleportWatchEnd: + self = .teleportWatchEnd + return case .openDeepLink: if let urlString = try? values.decode(String.self, forKey: .link), let url = URL(string: urlString) { 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 f8d92b9955..1079e3ae3b 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 @@ -20,7 +20,9 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func eventDidOccur(_ paywallWebEvent: PaywallWebEvent) func openDeepLink(_ url: URL, shouldDismiss: Bool) func presentSafariInApp(_ url: URL) - func presentSafariExternal(_ url: URL) + func presentSafariExternal(_ url: URL, drawsWaitingScreen: Bool) + func startTeleportWatch(_ watch: TeleportReturnCover.Watch) + func endTeleportWatch() func requestReview(type: ReviewType) func openPaymentSheet(_ url: URL) func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) @@ -44,10 +46,16 @@ final class PaywallMessageHandler: WebEventDelegate { struct EnqueuedMessage { let name: String let paywall: Paywall + var payload: [String: Any] = [:] } /// Used to enqueue `paywall_open` messages if the paywall isn't ready to receive them yet. private var messageQueue: Queue = Queue() + /// Tracks an event. Replaced in tests to observe what's tracked. + var trackEvent: (Trackable) async -> Void = { event in + _ = await Superwall.shared.track(event) + } + init( receiptManager: ReceiptManager, factory: VariablesFactory, @@ -87,15 +95,17 @@ final class PaywallMessageHandler: WebEventDelegate { delegate?.eventDidOccur(.closed) case .paywallOpen: let paywallOpen = SuperwallEventObjc.paywallOpen.description + let payload = presentationPayload() if delegate?.paywall.paywalljsVersion == nil { let message = EnqueuedMessage( name: paywallOpen, - paywall: paywall + paywall: paywall, + payload: payload ) messageQueue.enqueue(message) } else { Task { - await self.pass(placement: paywallOpen, from: paywall) + await self.pass(placement: paywallOpen, from: paywall, payload: payload) } } case .paywallClose: @@ -182,8 +192,16 @@ final class PaywallMessageHandler: WebEventDelegate { } case .openUrl(let url): openUrl(url) - case .openUrlInSafari(let url): - openUrlInSafari(url) + case let .openUrlInSafari(url, drawsWaitingScreen, isTeleport): + openUrlInSafari( + url, + drawsWaitingScreen: drawsWaitingScreen, + isTeleport: isTeleport + ) + case .teleportWatchStart(let watch): + delegate?.startTeleportWatch(watch) + case .teleportWatchEnd: + delegate?.endTeleportWatch() case .openPaymentSheet(let url): openPaymentSheet(url) case .openDeepLink(let url): @@ -277,6 +295,19 @@ final class PaywallMessageHandler: WebEventDelegate { } } + /// How this presentation came about, so purchases the paywall starts are attributed to it. + private func presentationPayload() -> [String: Any] { + guard let info = delegate?.info else { + return [:] + } + var payload: [String: Any] = ["presented_by": info.presentedBy] + payload["presentation_id"] = info.presentationId + payload["presentation_source_type"] = info.presentationSourceType + payload["presented_by_event_id"] = info.presentedByPlacementWithId + payload["presented_by_event_name"] = info.presentedByPlacementWithName + return payload + } + nonisolated private func pass( placement: String, from paywall: Paywall, @@ -475,7 +506,8 @@ final class PaywallMessageHandler: WebEventDelegate { Task { await self.pass( placement: message.name, - from: message.paywall + from: message.paywall, + payload: message.payload ) } } @@ -497,14 +529,24 @@ final class PaywallMessageHandler: WebEventDelegate { #endif } - private func openUrlInSafari(_ url: URL) { + private func openUrlInSafari( + _ url: URL, + drawsWaitingScreen: Bool = false, + isTeleport: Bool = false + ) { detectHiddenPaywallEvent( "openUrlInSafari", userInfo: ["url": url] ) hapticFeedback() delegate?.eventDidOccur(.openedUrlInSafari(url)) - delegate?.presentSafariExternal(url) + if isTeleport { + trackTeleportOpen() + } + delegate?.presentSafariExternal( + url, + drawsWaitingScreen: drawsWaitingScreen + ) } private func openPaymentSheet(_ url: URL) { @@ -586,6 +628,15 @@ final class PaywallMessageHandler: WebEventDelegate { } } + private func trackTeleportOpen() { + guard let delegate = delegate else { return } + let event = InternalSuperwallEvent.TeleportOpen(paywallInfo: delegate.info) + let trackEvent = self.trackEvent + Task { + await trackEvent(event) + } + } + private func detectHiddenPaywallEvent( _ eventName: String, userInfo: [String: Any]? = nil diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift index dec62099c5..7f9a4aa01b 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift @@ -52,7 +52,7 @@ class SWWebView: WKWebView { isMac: Bool, messageHandler: PaywallMessageHandler, isOnDeviceCacheEnabled: Bool, - factory: FeatureFlagsFactory & DeviceHelperFactory + factory: FeatureFlagsFactory & DeviceHelperFactory & OptionsFactory ) { self.isMac = isMac self.messageHandler = messageHandler @@ -131,6 +131,17 @@ class SWWebView: WKWebView { ) ) } + if let environmentSource = SubscriptionsApiEnvironmentScript.source( + for: factory.makeSuperwallOptions().networkEnvironment + ) { + wkConfig.userContentController.addUserScript( + WKUserScript( + source: environmentSource, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + ) + } self.navigationDelegate = self translatesAutoresizingMaskIntoConstraints = false diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift new file mode 100644 index 0000000000..89e7630ea7 --- /dev/null +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift @@ -0,0 +1,29 @@ +// +// SubscriptionsApiEnvironmentScript.swift +// SuperwallKit +// + +import Foundation + +/// Tells paywall.js which subscriptions-api to call for web checkouts and teleports. +/// +/// A native paywall is served from the same CDN host in every environment, so the +/// page cannot infer the environment from its own URL the way a hosted web paywall +/// can. Without this it falls back to production, which silently breaks a +/// `.developer` or `.local` SDK testing a checkout page against that stack. +enum SubscriptionsApiEnvironmentScript { + /// The script source, or `nil` when the environment is production or unknown and + /// paywall.js should keep its own default. + static func source(for environment: SuperwallOptions.NetworkEnvironment) -> String? { + let value: String + switch environment { + case .developer: + value = "staging" + case .local: + value = "local" + case .release, .releaseCandidate, .custom: + return nil + } + return "window.__SW_SUBSCRIPTIONS_API_ENV__ = \"\(value)\";" + } +} diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index cb13e4ae55..4099c4201c 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -166,6 +166,16 @@ actor WebEntitlementRedeemer { return true } + private func waitForActiveStripePoll() async { + let waitStart = DispatchTime.now().uptimeNanoseconds + while hasActiveStripePoll, !Task.isCancelled { + if DispatchTime.now().uptimeNanoseconds - waitStart >= stripePendingPollTimeoutNs { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + /// Either starts a new poll or waits for an existing in-flight poll to /// finish. Returns `true` if the checkout was redeemed. func pollOrWaitForActiveStripePoll() async -> Bool { @@ -242,16 +252,27 @@ actor WebEntitlementRedeemer { ) } - let outcome = await pollStripeRedemptionResult( + var outcome = await pollStripeRedemptionResult( contextId: contextId, productId: productId, trigger: .checkoutComplete, completion: completion ) + + // A poll already in flight is for another checkout (an older pending one, from the + // foreground or paywall open), so it never redeems this one. Wait it out, then poll this + // checkout: skipping left the spinner up with nothing left to take it down. + while outcome == .skippedInFlight, !Task.isCancelled { + await waitForActiveStripePoll() + outcome = await pollStripeRedemptionResult( + contextId: contextId, + productId: productId, + trigger: .checkoutComplete, + completion: completion + ) + } - // Don't hide spinner if another poll is in-flight — it will handle the - // loading state when it finishes. - if outcome != .redeemed, outcome != .skippedInFlight { + if outcome != .redeemed { let superwall = self.superwall ?? Superwall.shared await MainActor.run { superwall.paywallViewController?.loadingState = .ready @@ -854,6 +875,10 @@ actor WebEntitlementRedeemer { clearPendingStripeCheckoutState() return .noRedemptionFound } + // A newer checkout took over the pending state: this one stops so that one is polled. + if let state = pendingStripeCheckoutState, state.checkoutContextId != contextId { + return .noRedemptionFound + } do { let response = try await network.pollRedemptionResult(request: request) diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 3a05c67527..582c1c11c9 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -246,6 +246,7 @@ 5EF4EE04BAA930A2CC4379A1 /* RawWebMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC91544C3B7D209A8D51397F /* RawWebMessageHandlerTests.swift */; }; 5F1F480AA12C8D17B179D96B /* PaywallViewControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A453816DBA43C08410D12DE6 /* PaywallViewControllerMock.swift */; }; 5FB78EB52A12BA704AD3AE31 /* EventsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFDA9310B29A0C918D7A292 /* EventsResponse.swift */; }; + 616D346F1180226BBF7496CE /* SubscriptionsApiEnvironmentScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7050CACBCD334F3BE707474 /* SubscriptionsApiEnvironmentScriptTests.swift */; }; 61AE58F17230AE1578B9FB19 /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = C22CA9431D5F791BE7A9BE27 /* Documentation.docc */; }; 61EC2A032D127B323D4A8124 /* ConfirmPaywallAssignmentOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC7F8602B38644BCCDAD159 /* ConfirmPaywallAssignmentOperatorTests.swift */; }; 62CF18234F0EC4775DA8F828 /* UIViewController+AsyncPresent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69A4D77D819DDB696834E1B7 /* UIViewController+AsyncPresent.swift */; }; @@ -331,6 +332,7 @@ 803BFA630F96B638E3BDE715 /* GameControllerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C52F36F0BFF59363EBB4C7 /* GameControllerEvent.swift */; }; 8088933E13346C69FEE58919 /* DebugPickerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97EAA0E1AE80E91ACDB9AEB /* DebugPickerLogicTests.swift */; }; 80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */; }; + 8135760014288BA11BF815EB /* TeleportReturnCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5BD096D982EFB2F73D8028 /* TeleportReturnCover.swift */; }; 81680E02D1693BF58E015C0C /* ASN1Decoder+UnkeyedDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D31BB6D0C57337C6E929D617 /* ASN1Decoder+UnkeyedDecodingContainer.swift */; }; 82060DEBCCB16E69508D249D /* WebProductPricingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64197E12432C67204F2FBBCF /* WebProductPricingTests.swift */; }; 822B2898CDD9C6E50816F62B /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9298A79020030E9A1357A6 /* API.swift */; }; @@ -648,6 +650,7 @@ F2D5ADFFC1DD842744E0160A /* StorePayment.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6EED7C7E264C38A1A7C3EFB /* StorePayment.swift */; }; F30B4A30D481E4B2ACF58E33 /* SK2TransactionListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C273F16E6EDA8803DE9DA47D /* SK2TransactionListener.swift */; }; F326AADBFE0083F8F18E81CE /* Date+WithinAnHourBefore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D3EA7000250D02303BEF81 /* Date+WithinAnHourBefore.swift */; }; + F35F87C3126C03232F83292F /* SubscriptionsApiEnvironmentScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05B9771726558AF75743BF08 /* SubscriptionsApiEnvironmentScript.swift */; }; F365F06BBA58055920FC751B /* SuperwallPlacementInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF73F40EFFEC58FA0D30DC4 /* SuperwallPlacementInfo.swift */; }; F3A7AF6D766960ECFE03E4B8 /* UIViewController+TopVc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759D7C0FCB370EB4FC33F4E4 /* UIViewController+TopVc.swift */; }; F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */; }; @@ -720,6 +723,7 @@ 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ReceiptManager.swift; sourceTree = ""; }; 054FCADFEF560A1A736DEFE4 /* StoreKitManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitManagerTests.swift; sourceTree = ""; }; 0596DAAE31B2242A59060C5F /* StorePresentationObjects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePresentationObjects.swift; sourceTree = ""; }; + 05B9771726558AF75743BF08 /* SubscriptionsApiEnvironmentScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsApiEnvironmentScript.swift; sourceTree = ""; }; 0695B39826F85AACBA833B77 /* InAppReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceipt.swift; sourceTree = ""; }; 0704117EF9F98A047714162B /* CustomStoreTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStoreTransaction.swift; sourceTree = ""; }; 072886BB8C0E08DF414D9162 /* InAppReceiptPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceiptPayload.swift; sourceTree = ""; }; @@ -1181,6 +1185,7 @@ ABC1253C6D5DD8D967BE05D1 /* LocalizationGrouping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationGrouping.swift; sourceTree = ""; }; ABD045A5C4A47B1CA9365285 /* MicrophonePermissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrophonePermissionTests.swift; sourceTree = ""; }; AC11F0D5B6B8A0F5EC5D200B /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + AC5BD096D982EFB2F73D8028 /* TeleportReturnCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeleportReturnCover.swift; sourceTree = ""; }; AC74F16DC5A17489E97061EA /* PushTransitionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionLogic.swift; sourceTree = ""; }; ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifest.swift; sourceTree = ""; }; AD23BBF9EA198ED8798A8F62 /* SystemInfo+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemInfo+NotificationName.swift"; sourceTree = ""; }; @@ -1360,6 +1365,7 @@ E4623C4E5EDA10B38746C384 /* LocationPermissionDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionDelegateTests.swift; sourceTree = ""; }; E4DC3F3B888F2DC4CC4747CB /* CacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheTests.swift; sourceTree = ""; }; E51D0B38180377D9CD3E65DA /* Date+TimeIntervalMilliseconds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeIntervalMilliseconds.swift"; sourceTree = ""; }; + E7050CACBCD334F3BE707474 /* SubscriptionsApiEnvironmentScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsApiEnvironmentScriptTests.swift; sourceTree = ""; }; E72593E1D4123B176EC83499 /* WebArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebArchive.swift; sourceTree = ""; }; E74C7DE0FAFE0C01F374DDF0 /* CoreDataManagerFakeDataMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManagerFakeDataMock.swift; sourceTree = ""; }; E7F1150DA75C81CB3815F2F4 /* ConfigurationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationStatus.swift; sourceTree = ""; }; @@ -1797,6 +1803,7 @@ children = ( 2A50766D6FBAFA61D1121B51 /* DevicePreloadScript.swift */, 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */, + 05B9771726558AF75743BF08 /* SubscriptionsApiEnvironmentScript.swift */, 9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */, 8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */, 4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */, @@ -1970,6 +1977,7 @@ children = ( AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */, 7569A0893012A88442B7ED36 /* PaywallViewController.swift */, + AC5BD096D982EFB2F73D8028 /* TeleportReturnCover.swift */, 6F9276EC956CE4A6C09949CE /* Delegates */, 1DE8CF6D5ACEFA6B6349B142 /* Loading */, 43AE802CA13E36803E2FF13E /* Popup Transition */, @@ -2024,6 +2032,7 @@ children = ( 01AC1F76564A6EC47EE696F9 /* DevicePreloadScriptTests.swift */, A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */, + E7050CACBCD334F3BE707474 /* SubscriptionsApiEnvironmentScriptTests.swift */, 23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */, 4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */, FD8F67EFF69ECDBE85EB24F5 /* Message Handling */, @@ -3896,6 +3905,7 @@ 899C32DE12E630CE296556FA /* SubscriptionStatusEmissionTests.swift in Sources */, 1B071A6918B1B36BF6BCC0C2 /* SubscriptionStatusLogicalEqualityTests.swift in Sources */, E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */, + 616D346F1180226BBF7496CE /* SubscriptionsApiEnvironmentScriptTests.swift in Sources */, ADFFF22341B34F9F28FE595B /* SupportEmailComposerTests.swift in Sources */, 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */, 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */, @@ -4328,6 +4338,7 @@ 332915728292DE705DB09338 /* SubscriptionStatusChange.swift in Sources */, C8540455BC520DAB75862A5D /* SubscriptionStatusPublishing.swift in Sources */, B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */, + F35F87C3126C03232F83292F /* SubscriptionsApiEnvironmentScript.swift in Sources */, 28A1E7AFF222A9C36F4AE019 /* Superwall+CustomerCenter.swift in Sources */, CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */, 17C0F1960CD89B6BFA8B2FBB /* SuperwallDelegate.swift in Sources */, @@ -4352,6 +4363,7 @@ 0A0BD14265AA8CE75A84940E /* TaskCoalescer.swift in Sources */, 1FFADB97F7E05549750E14E9 /* TaskExecutor.swift in Sources */, D916475C6CE464EEB094F419 /* TaskRetryLogic.swift in Sources */, + 8135760014288BA11BF815EB /* TeleportReturnCover.swift in Sources */, 25E2A4570B63FE36E4DD4E52 /* TemplateLogic.swift in Sources */, E0547EDA2BB0A807656EBFE7 /* TestModeDeviceAttributesViewController.swift in Sources */, FA382AF6BA204F0B158B7175 /* TestModeEntitlementRowView.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift index 4e9aa19859..145797048d 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift @@ -2820,4 +2820,56 @@ struct TrackingTests { #expect( result.parameters.audienceFilterParams["$paywall_count"] as! Int == paywallCount) } + + @Test func teleportEvents_namesAndObjcMirrors() { + let paywallInfo: PaywallInfo = .stub() + #expect(SuperwallEvent.teleportOpen(paywallInfo: paywallInfo).description == "teleport_open") + #expect(SuperwallEvent.teleportReturn(paywallInfo: nil).description == "teleport_return") + #expect( + SuperwallEventObjc(event: .teleportOpen(paywallInfo: paywallInfo)) + == .teleportOpen) + #expect(SuperwallEventObjc(event: .teleportReturn(paywallInfo: paywallInfo)) == .teleportReturn) + } + + @Test func teleportOpen() async { + let paywallInfo: PaywallInfo = .stub() + let result = await Superwall.shared.track( + InternalSuperwallEvent.TeleportOpen(paywallInfo: paywallInfo)) + #expect(result.parameters.audienceFilterParams["$app_session_id"] != nil) + #expect(result.parameters.audienceFilterParams["$is_standard_event"] as! Bool) + #expect( + result.parameters.audienceFilterParams["$event_name"] as! String == "teleport_open") + #expect( + result.parameters.audienceFilterParams["$paywall_id"] as! String == paywallInfo.databaseId) + #expect( + result.parameters.audienceFilterParams["$paywall_identifier"] as! String == + paywallInfo.identifier) + // The page's URL carries its sealed teleport, so it stays out of analytics. + #expect(result.parameters.audienceFilterParams["$url"] == nil) + #expect( + result.parameters.audienceFilterParams["paywall_id"] as! String == paywallInfo.databaseId) + } + + @Test func teleportReturn_withPresentedPaywall() async { + let paywallInfo: PaywallInfo = .stub() + let result = await Superwall.shared.track( + InternalSuperwallEvent.TeleportReturn(paywallInfo: paywallInfo)) + #expect(result.parameters.audienceFilterParams["$app_session_id"] != nil) + #expect(result.parameters.audienceFilterParams["$is_standard_event"] as! Bool) + #expect( + result.parameters.audienceFilterParams["$event_name"] as! String == "teleport_return") + #expect( + result.parameters.audienceFilterParams["$paywall_id"] as! String == paywallInfo.databaseId) + #expect( + result.parameters.audienceFilterParams["paywall_id"] as! String == paywallInfo.databaseId) + } + + @Test func teleportReturn_withoutPresentedPaywall() async { + let result = await Superwall.shared.track( + InternalSuperwallEvent.TeleportReturn(paywallInfo: nil)) + #expect(result.parameters.audienceFilterParams["$app_session_id"] != nil) + #expect( + result.parameters.audienceFilterParams["$event_name"] as! String == "teleport_return") + #expect(result.parameters.audienceFilterParams["$paywall_id"] == nil) + } } diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 281f6c349c..577c1a3853 100644 --- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift +++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift @@ -188,6 +188,7 @@ struct DeepLinkRouterTests { arguments: [ "myapp://superwall/return", "myapp://superwall/return?source=checkout", + "https://myapp.superwall.link/app-link/superwall/return", "https://myapp.superwall.app/app-link/superwall/return", "https://myapp.superwallapp.dev/app-link/superwall/return", ] @@ -223,4 +224,101 @@ struct DeepLinkRouterTests { ) == true ) } + + // MARK: - Return Link Tracking + + private final class TrackedEvents: @unchecked Sendable { + private let lock = NSLock() + private var events: [Trackable] = [] + + func append(_ event: Trackable) { + lock.lock() + defer { lock.unlock() } + events.append(event) + } + + var all: [Trackable] { + lock.lock() + defer { lock.unlock() } + return events + } + } + + private func routeAndCollect( + _ url: URL, + presentedPaywallInfo: PaywallInfo? + ) async -> (handled: Bool, events: [Trackable]) { + let dependencyContainer = DependencyContainer() + let router = dependencyContainer.deepLinkRouter! + let tracked = TrackedEvents() + router.trackEvent = { tracked.append($0) } + router.presentedPaywallInfo = { presentedPaywallInfo } + + let handled = router.route(url: url) + + // Wait for the first event, then a little longer so a stray second one would show. + for _ in 0..<50 where tracked.all.isEmpty { + try? await Task.sleep(nanoseconds: 20_000_000) + } + try? await Task.sleep(nanoseconds: 100_000_000) + return (handled, tracked.all) + } + + @Test("A return link tracks teleport_return with the presented paywall's info, not deepLink_open") + func route_returnLink_tracksTeleportReturn() async { + let paywallInfo: PaywallInfo = .stub() + let result = await routeAndCollect( + URL(string: "myapp://superwall/return")!, + presentedPaywallInfo: paywallInfo + ) + + #expect(result.handled == true) + #expect(result.events.count == 1) + let event = result.events.first as? InternalSuperwallEvent.TeleportReturn + #expect(event?.superwallEvent.description == "teleport_return") + #expect(event?.paywallInfo?.databaseId == paywallInfo.databaseId) + #expect(!result.events.contains(where: { $0 is InternalSuperwallEvent.DeepLink })) + } + + @Test("A return link carries the checkout page's reason on teleport_return") + func route_returnLink_tracksReason() async { + let purchased = await routeAndCollect( + URL(string: "myapp://superwall/return?reason=purchased")!, + presentedPaywallInfo: nil + ) + let closed = await routeAndCollect( + URL(string: "https://myapp.superwall.link/app-link/superwall/return?reason=closed")!, + presentedPaywallInfo: nil + ) + + #expect((purchased.events.first as? InternalSuperwallEvent.TeleportReturn)?.reason == "purchased") + #expect((closed.events.first as? InternalSuperwallEvent.TeleportReturn)?.reason == "closed") + #expect(purchased.handled == true) + #expect(closed.handled == true) + } + + @Test("A return link with no paywall presented tracks teleport_return without paywall info") + func route_returnLink_noPresentedPaywall() async { + let result = await routeAndCollect( + URL(string: "https://myapp.superwall.link/app-link/superwall/return")!, + presentedPaywallInfo: nil + ) + + #expect(result.handled == true) + #expect(result.events.count == 1) + let event = result.events.first as? InternalSuperwallEvent.TeleportReturn + #expect(event != nil) + #expect(event?.paywallInfo == nil) + } + + @Test("Any other deep link still tracks deepLink_open and not teleport_return") + func route_otherLink_tracksDeepLinkOnly() async { + let result = await routeAndCollect( + URL(string: "myapp://home")!, + presentedPaywallInfo: .stub() + ) + + #expect(result.events.contains(where: { $0 is InternalSuperwallEvent.DeepLink })) + #expect(!result.events.contains(where: { $0 is InternalSuperwallEvent.TeleportReturn })) + } } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift index 8bed19305d..ab29af65dd 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift @@ -44,6 +44,8 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { var didPresentSafariInApp = false var didOpenDeepLink = false var deepLinkShouldDismiss: Bool? + var teleportWatch: TeleportReturnCover.Watch? + var didDrawWaitingScreen = false var didPresentSafariExternal = false var didRequestReview = false var didOpenPaymentSheet = false @@ -85,8 +87,17 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { didPresentSafariInApp = true } - func presentSafariExternal(_ url: URL) { + func startTeleportWatch(_ watch: TeleportReturnCover.Watch) { + teleportWatch = watch + } + + func endTeleportWatch() { + teleportWatch = nil + } + + func presentSafariExternal(_ url: URL, drawsWaitingScreen: Bool) { didPresentSafariExternal = true + didDrawWaitingScreen = drawsWaitingScreen } func requestReview(type: ReviewType) { diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index ba0294ad62..2a8e369cad 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -77,6 +77,21 @@ struct PaywallMessageHandlerTests { return (messageHandler, webView, delegate) } + // The paywall attributes purchases it starts (web checkouts, teleports) to how it was presented. + @Test + func paywallOpen_sendsThePresentation() async { + let (messageHandler, webView, delegate) = makeHandler() + delegate.paywall.paywalljsVersion = "2" + + messageHandler.handle(.paywallOpen) + + let open = await waitForEvent(named: "paywall_open", in: webView) + #expect(open?["presented_by"] as? String == "programmatically") + #expect(open?["presentation_source_type"] as? String == "register") + #expect(open?["presentation_id"] == nil) + #expect(open?["presented_by_event_id"] == nil) + } + // Regression: the paywall schedules its trial reminder off `freeTrial_start`, so a purchase // that didn't start a trial (e.g. the user already used it) must not send that message. @Test @@ -260,6 +275,77 @@ struct PaywallMessageHandlerTests { #expect(delegate.didPresentSafariExternal == true) } + @Test + func openUrlInSafari_forcesSafariWhenAsked() { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + let url = URL(string: "https://example.com/sw-teleport/abc")! + messageHandler.handle(.openUrlInSafari(url, drawsWaitingScreen: true)) + + #expect(delegate.didPresentSafariExternal == true) + #expect(delegate.didDrawWaitingScreen == true) + } + + @Test(arguments: [true, false]) + func openUrlInSafari_tracksTeleportOpenOnlyForTeleports(isTeleport: Bool) async { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + var tracked: [Trackable] = [] + messageHandler.trackEvent = { tracked.append($0) } + + let url = URL(string: "https://example.com/sw-teleport/abc?token=secret#ref=primary")! + messageHandler.handle(.openUrlInSafari(url, isTeleport: isTeleport)) + + for _ in 0..<15 where tracked.isEmpty { + try? await Task.sleep(nanoseconds: 20_000_000) + } + + #expect(delegate.didPresentSafariExternal == true) + guard isTeleport else { + #expect(tracked.isEmpty) + return + } + #expect(tracked.count == 1) + let event = tracked.first as? InternalSuperwallEvent.TeleportOpen + #expect(event?.superwallEvent.description == "teleport_open") + #expect(event?.paywallInfo.databaseId == delegate.info.databaseId) + let params = await event?.getSuperwallParameters() + #expect(params?["url"] == nil) + #expect(params?["paywall_id"] as? String == delegate.info.databaseId) + } + @Test func openDeepLink_regularDeepLink_shouldDismiss() { let dependencyContainer = DependencyContainer() @@ -595,6 +681,103 @@ struct PaywallMessageHandlerTests { )) } + // MARK: - Open URL External Decoding Tests + + @Test(arguments: [ + (nil, false), + (false, false), + (true, true), + ] as [(Bool?, Bool)]) + func decodeOpenUrlExternal_waitingScreen(waitingScreen: Bool?, draws: Bool) throws { + let field = waitingScreen.map { ", \"waiting_screen\": \($0)" } ?? "" + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "eventName": "open_url_external", + "url": "https://example.com/sw-teleport/abc"\(field) + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages.first == .openUrlInSafari( + URL(string: "https://example.com/sw-teleport/abc")!, + drawsWaitingScreen: draws + )) + } + + @Test(arguments: [ + (nil, false), + (false, false), + (true, true), + ] as [(Bool?, Bool)]) + func decodeOpenUrlExternal_teleport(teleport: Bool?, isTeleport: Bool) throws { + let field = teleport.map { ", \"teleport\": \($0)" } ?? "" + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "eventName": "open_url_external", + "url": "https://example.com/sw-teleport/abc", + "waiting_screen": true\(field) + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages.first == .openUrlInSafari( + URL(string: "https://example.com/sw-teleport/abc")!, + drawsWaitingScreen: true, + isTeleport: isTeleport + )) + } + + @Test + func decodeTeleportWatch() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "eventName": "teleport_watch_start", + "teleport_id": "v1:teleport", + "teleport_status_url": "https://subs.example.com/teleport/status", + "checkout_status_url": "https://subs.example.com/checkout/status", + "public_api_key": "pk_test" + }, + { "eventName": "teleport_watch_end" } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages == [ + .teleportWatchStart( + TeleportReturnCover.Watch( + teleportId: "v1:teleport", + teleportStatusUrl: URL(string: "https://subs.example.com/teleport/status")!, + checkoutStatusUrl: URL(string: "https://subs.example.com/checkout/status")!, + publicApiKey: "pk_test" + ) + ), + .teleportWatchEnd, + ]) + } + // MARK: - Haptic Feedback Message Decoding Tests @Test diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScriptTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScriptTests.swift new file mode 100644 index 0000000000..5f547156ea --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScriptTests.swift @@ -0,0 +1,32 @@ +// +// SubscriptionsApiEnvironmentScriptTests.swift +// SuperwallKitTests +// + +import Testing +@testable import SuperwallKit + +struct SubscriptionsApiEnvironmentScriptTests { + @Test("Points a developer SDK at the staging subscriptions-api") + func developerIsStaging() { + #expect( + SubscriptionsApiEnvironmentScript.source(for: .developer) + == #"window.__SW_SUBSCRIPTIONS_API_ENV__ = "staging";"# + ) + } + + @Test("Points a local SDK at the local subscriptions-api") + func localIsLocal() { + #expect( + SubscriptionsApiEnvironmentScript.source(for: .local) + == #"window.__SW_SUBSCRIPTIONS_API_ENV__ = "local";"# + ) + } + + @Test("Leaves paywall.js on its production default otherwise") + func productionAndCustomInjectNothing() { + #expect(SubscriptionsApiEnvironmentScript.source(for: .release) == nil) + #expect(SubscriptionsApiEnvironmentScript.source(for: .releaseCandidate) == nil) + #expect(SubscriptionsApiEnvironmentScript.source(for: .custom("https://example.com")) == nil) + } +} From a661d41d1eee5a75b82340ce4dd765147c212141 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 8 Oct 2026 17:59:58 +0200 Subject: [PATCH 03/13] fix: address teleport review findings and keep web purchase completion through recovery --- .../Basic/Basic.xcodeproj/project.pbxproj | 4 +- Sources/SuperwallKit/DeepLinkRouter.swift | 62 +++--- .../PaywallViewController.swift | 8 +- .../View Controller/TeleportReturnCover.swift | 30 ++- .../Message Handling/PaywallMessage.swift | 2 +- .../Web/PendingStripeCheckoutPollState.swift | 19 ++ .../Web/WebEntitlementRedeemer.swift | 44 +++- .../Web/WebEntitlementRedeemerTests.swift | 209 +++++++++++++++++- 8 files changed, 328 insertions(+), 50 deletions(-) diff --git a/Examples/Basic/Basic.xcodeproj/project.pbxproj b/Examples/Basic/Basic.xcodeproj/project.pbxproj index ff39b7bed8..74bd94247c 100644 --- a/Examples/Basic/Basic.xcodeproj/project.pbxproj +++ b/Examples/Basic/Basic.xcodeproj/project.pbxproj @@ -375,7 +375,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Basic/Design/Assets\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5DL44U5BHU; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Basic/Info.plist; @@ -410,7 +410,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Basic/Design/Assets\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5DL44U5BHU; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Basic/Info.plist; diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index 94ee84c974..8c3ab167b3 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -43,35 +43,7 @@ final class DeepLinkRouter { // 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 { - // 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 - ) - ) - } + handleReturnLink(url) return true } @@ -127,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( diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 8a50f62b3c..4a97b9142c 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1691,10 +1691,14 @@ extension PaywallViewController { override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) existingTeleportReturnCover?.reset() - guard isPresented else { + 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 { diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift index 8493cc68dc..028ec0118c 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -2,6 +2,7 @@ // TeleportReturnCover.swift // SuperwallKit // +// swiftlint:disable file_length type_body_length import UIKit import WebKit @@ -260,8 +261,9 @@ final class TeleportReturnCover: NSObject { return } backgroundTask = application.beginBackgroundTask(withName: "Superwall checkout") { [weak self] in - // Out of time before the check finished: cover the screen while iOS still listens. - Task { @MainActor in + // Out of time before the check finished: cover the screen while iOS still listens. iOS + // calls this on the main thread and expects the task ended before it returns, so no hop. + MainActor.assumeIsolated { guard let self else { return } @@ -364,10 +366,14 @@ final class TeleportReturnCover: NSObject { /// Has the page remove the waiting screen, then takes the cover off once that is painted. private func uncover() { - guard returnCover != nil, uncovering == nil else { + guard let returnCover, uncovering == nil else { return } revealing = nil + // A fade in progress (back by hand, then the return link) stops: the cover stays whole + // until the page has taken the waiting screen down. + returnCover.view.layer.removeAllAnimations() + returnCover.view.alpha = 1 let id = UUID() uncovering = id if #available(iOS 14.0, *), let webView { @@ -428,16 +434,18 @@ private extension TeleportReturnCover { revealing = id Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: Self.revealGrace) + guard let self, self.revealing == id else { + return + } + // Whatever happens next, the next activation may try again. + self.revealing = nil guard - let self, - self.revealing == id, let returnCover = self.returnCover, returnCover.reason == .suspending, UIApplication.sharedApplication?.applicationState == .active else { return } - self.revealing = nil // The cover is the paywall, so this reads as the waiting screen coming up over it. UIView.animate( withDuration: Self.revealDuration, @@ -445,8 +453,14 @@ private extension TeleportReturnCover { options: [.curveEaseOut, .allowUserInteraction] ) { returnCover.view.alpha = 0 - } completion: { [weak self] _ in - guard let self, self.returnCover?.view === returnCover.view else { + } completion: { [weak self] finished in + // A return link that arrived mid-fade took the cover over; it comes off with the page. + guard + let self, + finished, + self.uncovering == nil, + self.returnCover?.view === returnCover.view + else { return } self.removeCover() 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 14704d4db2..4866fb7c65 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 @@ -274,7 +274,7 @@ enum PaywallMessage: Decodable, Equatable { case .stripeCheckoutComplete: if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), let productId = try? values.decode(String.self, forKey: .productId) { - let shouldDismiss = (try? values.decodeIfPresent(Bool.self, forKey: .shouldDismiss)) ?? nil + let shouldDismiss = try? values.decodeIfPresent(Bool.self, forKey: .shouldDismiss) self = .stripeCheckoutComplete( checkoutContextId: checkoutContextId, productId: productId, diff --git a/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift b/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift index 2fbc4a1028..18dcc92447 100644 --- a/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift +++ b/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift @@ -12,25 +12,44 @@ struct PendingStripeCheckoutPollState: Codable, Equatable { let checkoutContextId: String let productId: String + /// How the checkout finishes once its code is redeemed. A recovery poll (on foreground or + /// paywall open) finishes it the way the paywall asked, not as a restore. + let completion: StripeCheckoutCompletion let remainingForegroundAttempts: Int let updatedAt: Date init( checkoutContextId: String, productId: String, + completion: StripeCheckoutCompletion = .restore, remainingForegroundAttempts: Int = defaultForegroundAttempts, updatedAt: Date = Date() ) { self.checkoutContextId = checkoutContextId self.productId = productId + self.completion = completion self.remainingForegroundAttempts = remainingForegroundAttempts self.updatedAt = updatedAt } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + checkoutContextId = try container.decode(String.self, forKey: .checkoutContextId) + productId = try container.decode(String.self, forKey: .productId) + // State saved by an SDK before the completion was stored finishes as a restore, as it did then. + completion = try container.decodeIfPresent( + StripeCheckoutCompletion.self, + forKey: .completion + ) ?? .restore + remainingForegroundAttempts = try container.decode(Int.self, forKey: .remainingForegroundAttempts) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } + func consumingForegroundAttempt() -> PendingStripeCheckoutPollState { PendingStripeCheckoutPollState( checkoutContextId: checkoutContextId, productId: productId, + completion: completion, remainingForegroundAttempts: max(remainingForegroundAttempts - 1, 0), updatedAt: Date() ) diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 4099c4201c..25a4924608 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -10,7 +10,8 @@ import Foundation import UIKit /// How a web checkout the paywall reported as complete finishes once its code is redeemed. -enum StripeCheckoutCompletion: Equatable { +/// Stored with the pending checkout, so a poll that recovers it later finishes it the same way. +enum StripeCheckoutCompletion: Equatable, Codable { /// Legacy: the paywall hears `restore_complete`, and the paywall dismisses /// as `.restored` once its entitlements are active. case restore @@ -166,6 +167,11 @@ actor WebEntitlementRedeemer { return true } + private func isRedeemed(_ contextId: String) -> Bool { + lastCompletedStripePollResult?.contextId == contextId + && lastCompletedStripePollResult?.outcome == .redeemed + } + private func waitForActiveStripePoll() async { let waitStart = DispatchTime.now().uptimeNanoseconds while hasActiveStripePoll, !Task.isCancelled { @@ -221,7 +227,8 @@ actor WebEntitlementRedeemer { let outcome = await pollStripeRedemptionResult( contextId: pendingState.checkoutContextId, productId: pendingState.productId, - trigger: .paywallOpen + trigger: .paywallOpen, + completion: pendingState.completion ) return outcome == .redeemed @@ -234,12 +241,26 @@ actor WebEntitlementRedeemer { ) async { awaitingCheckoutComplete = false + // The page can report one purchase more than once (its status poll and the return check). + // Redeemed once is enough: a second redemption would put the spinner back up behind the + // success alert, with nothing left to take it down. + if isRedeemed(contextId) { + let superwall = self.superwall ?? Superwall.shared + await MainActor.run { + if superwall.paywallViewController?.presentedViewController == nil { + superwall.paywallViewController?.loadingState = .ready + } + } + return + } + if let existingState = pendingStripeCheckoutState, existingState.checkoutContextId == contextId { savePendingStripeCheckoutState( .init( checkoutContextId: contextId, productId: productId, + completion: completion, remainingForegroundAttempts: existingState.remainingForegroundAttempts ) ) @@ -247,7 +268,8 @@ actor WebEntitlementRedeemer { savePendingStripeCheckoutState( .init( checkoutContextId: contextId, - productId: productId + productId: productId, + completion: completion ) ) } @@ -258,12 +280,16 @@ actor WebEntitlementRedeemer { trigger: .checkoutComplete, completion: completion ) - + // A poll already in flight is for another checkout (an older pending one, from the // foreground or paywall open), so it never redeems this one. Wait it out, then poll this // checkout: skipping left the spinner up with nothing left to take it down. while outcome == .skippedInFlight, !Task.isCancelled { await waitForActiveStripePoll() + if isRedeemed(contextId) { + outcome = .redeemed + break + } outcome = await pollStripeRedemptionResult( contextId: contextId, productId: productId, @@ -313,7 +339,8 @@ actor WebEntitlementRedeemer { let outcome = await pollStripeRedemptionResult( contextId: pendingState.checkoutContextId, productId: pendingState.productId, - trigger: .foreground + trigger: .foreground, + completion: pendingState.completion ) // Consume foreground attempts after each trigger completes, except when skipped @@ -478,6 +505,7 @@ actor WebEntitlementRedeemer { let (allEntitlements, paywallEntitlementIds) = await processEntitlements( response: response, type: type, + completion: completion, superwall: superwall ) @@ -506,6 +534,7 @@ actor WebEntitlementRedeemer { private func processEntitlements( response: RedeemResponse, type: RedeemType, + completion: StripeCheckoutCompletion, superwall: Superwall ) async -> (allEntitlements: Set, paywallEntitlementIds: Set) { let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() @@ -516,7 +545,10 @@ actor WebEntitlementRedeemer { var paywallEntitlementIds: Set = [] - if case .code = type, let paywallVc = superwall.paywallViewController { + // A restore is only complete once every entitlement the paywall offers is active, and the + // paywall hears restore_complete or restore_fail. A purchase bought one product, so none of + // that applies: it hears transaction_complete instead. + if case .code = type, completion == .restore, let paywallVc = superwall.paywallViewController { for id in await paywallVc.info.productIds { let entitlements = superwall.entitlements.byProductId(id) paywallEntitlementIds.formUnion(entitlements.map { $0.id }) diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index dbe4689ad1..7c3842583b 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -2166,6 +2166,68 @@ struct WebEntitlementRedeemerTests { } } + @Test("A purchase the page reports twice is redeemed once") + func testStripeCheckoutComplete_reportedTwice_redeemsOnce() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let entitlements: Set = [.stub()] + let result = RedemptionResult.success( + code: "redemption_123", + redemptionInfo: .init( + ownership: .appUser(appUserId: "appUserId"), + purchaserInfo: .init( + appUserId: "appUserId", + email: nil, + storeIdentifiers: .stripe(customerId: "cus_123", subscriptionIds: ["sub_123"]) + ), + entitlements: entitlements + ) + ) + mockNetwork.pollRedemptionResultResponses = [ + .success( + RedeemResponse( + results: [result], + customerInfo: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: Array(entitlements) + ) + ) + ) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") + + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + #expect(mockDelegate.willRedeemCallCount == 1) + } + @Test("Legacy redeem keeps callback compatibility: willRedeemLink fires before /redeem request") func testLegacyRedeem_callbacksCompatibility() async { @@ -2927,7 +2989,8 @@ struct WebEntitlementRedeemerTests { private func makeWebPurchaseHarness( result: RedemptionResult? = nil, - automaticallyDismiss: Bool = true + automaticallyDismiss: Bool = true, + paywall: Paywall = .stub() ) async -> WebPurchaseHarness { let cache = dependencyContainer.paywallManager.cache let messageHandler = await PaywallMessageHandler( @@ -2943,7 +3006,7 @@ struct WebEntitlementRedeemerTests { factory: dependencyContainer ) let paywallVc = await PaywallViewControllerMock( - paywall: .stub(), + paywall: paywall, deviceHelper: dependencyContainer.deviceHelper, factory: dependencyContainer, storage: dependencyContainer.storage, @@ -3207,6 +3270,148 @@ struct WebEntitlementRedeemerTests { let restored = paywallVc.resolvedDismissal(result: .restored, closeReason: .systemLogic) #expect(restored.result == .restored) } + + @Test("A purchase does not outlive its presentation: a later close is a decline") + @MainActor + func testCompletedPurchase_clearedWhenViewDisappears() { + let paywallVc = PaywallViewController( + paywall: .stub(), + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: SWWebView( + isMac: false, + messageHandler: PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ), + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ), + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: nil, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + + paywallVc.markPurchaseCompleted(StoreProduct.blank(productIdentifier: "stripe|price_1")) + paywallVc.viewDidDisappear(false) + + let later = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) + #expect(later.result == .declined) + #expect(later.closeReason == .manualClose) + #expect(paywallVc.completedPurchaseProduct == nil) + } + + /// A paywall offering two tiers with different entitlements. A web purchase of one tier + /// activates only that tier's entitlement. + private func makeTieredPaywall() -> Paywall { + var paywall = Paywall.stub() + paywall.productIds = ["stripe|price_1", "stripe|price_2"] + dependencyContainer.entitlementsInfo.entitlementsByProductId["stripe|price_1"] = [.stub()] + dependencyContainer.entitlementsInfo.entitlementsByProductId["stripe|price_2"] = [ + Entitlement(id: "other_tier") + ] + return paywall + } + + @Test("A web purchase of one tier is not judged as a restore of every tier") + func testStripeCheckoutComplete_purchase_skipsRestoreValidation() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness(paywall: makeTieredPaywall()) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: false) + ) + + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + let webEvents = await sentEventNames(harness.webView) + #expect(!webEvents.contains("restore_fail")) + #expect(!webEvents.contains("restore_complete")) + let events = harness.delegate.eventsReceived.map { $0.backingData.objcEvent } + #expect(!events.contains(SuperwallEventObjc.restoreFail)) + #expect(!events.contains(SuperwallEventObjc.restoreComplete)) + #expect(await harness.paywallVc.loadingState == .ready) + } + + @Test("A legacy web checkout completion of one tier still reports the restore as failed") + func testStripeCheckoutComplete_restore_keepsRestoreValidation() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness(paywall: makeTieredPaywall()) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1" + ) + + let events = harness.delegate.eventsReceived.map { $0.backingData.objcEvent } + #expect(events.contains(SuperwallEventObjc.restoreFail)) + #expect(await waitForEvent("transaction_complete", in: harness.webView) == false) + } + + @Test("A web purchase recovered on foreground after a failed poll still finishes as a purchase") + func testStripeForegroundRecovery_keepsPurchaseCompletion() async { + guard #available(iOS 14.0, *) else { + return + } + let harness = await makeWebPurchaseHarness() + harness.network.pollRedemptionResultResponses.insert( + .failure(NetworkError.notAuthenticated), + at: 0 + ) + + await harness.redeemer.handleStripeCheckoutComplete( + contextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: true) + ) + #expect(harness.network.pollRedemptionResultCallCount == 1) + #expect(await waitForEvent("transaction_complete", in: harness.webView) == false) + #expect(await harness.paywallVc.loadingState == .ready) + + await harness.redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + + #expect(harness.network.pollRedemptionResultCallCount == 2) + #expect(await waitForEvent("transaction_complete", in: harness.webView)) + let results = await harness.finishes.waitForResults() + if case let .purchased(product) = results.first { + #expect(product.productIdentifier == "stripe|price_1") + } else { + Issue.record("Expected a purchased result, got \(String(describing: results.first))") + } + } + + @Test("Pending checkout state keeps its completion, and state saved without one restores") + func testPendingStripeCheckoutPollState_completionRoundTrip() throws { + let purchase = PendingStripeCheckoutPollState( + checkoutContextId: "ctx_1", + productId: "stripe|price_1", + completion: .purchase(productId: "stripe|price_1", shouldDismiss: false) + ) + let data = try JSONEncoder().encode(purchase) + let decoded = try JSONDecoder().decode(PendingStripeCheckoutPollState.self, from: data) + #expect(decoded == purchase) + #expect(decoded.consumingForegroundAttempt().completion == purchase.completion) + + let legacyJson = """ + {"checkoutContextId":"ctx_1","productId":"stripe|price_1","remainingForegroundAttempts":5,"updatedAt":0} + """ + let legacy = try JSONDecoder().decode( + PendingStripeCheckoutPollState.self, + from: Data(legacyJson.utf8) + ) + #expect(legacy.completion == .restore) + #expect(legacy.checkoutContextId == "ctx_1") + } } /// Records how a paywall finished. From 52d39f82f5a03c92dc4c374bf2ec5164eefd8e75 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 8 Oct 2026 18:07:09 +0200 Subject: [PATCH 04/13] chore: keep the Basic example's development team empty --- Examples/Basic/Basic.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/Basic/Basic.xcodeproj/project.pbxproj b/Examples/Basic/Basic.xcodeproj/project.pbxproj index 74bd94247c..ff39b7bed8 100644 --- a/Examples/Basic/Basic.xcodeproj/project.pbxproj +++ b/Examples/Basic/Basic.xcodeproj/project.pbxproj @@ -375,7 +375,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Basic/Design/Assets\""; - DEVELOPMENT_TEAM = 5DL44U5BHU; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Basic/Info.plist; @@ -410,7 +410,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Basic/Design/Assets\""; - DEVELOPMENT_TEAM = 5DL44U5BHU; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Basic/Info.plist; From d67aff87d8b0a86a21f09ca4308b3eb038c2de30 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 8 Oct 2026 18:21:53 +0200 Subject: [PATCH 05/13] test: cover the return cover's interrupted grace and mid-fade return link --- .../View Controller/TeleportReturnCover.swift | 121 +++++++++++----- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../TeleportReturnCoverTests.swift | 135 ++++++++++++++++++ 3 files changed, 222 insertions(+), 38 deletions(-) create mode 100644 Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift index 028ec0118c..badb0e5e04 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -50,21 +50,49 @@ final class TeleportReturnCover: NSObject { case suspending } - /// How long leaving waits for the page to draw the waiting screen. - private static let drawWait: UInt64 = 500_000_000 - /// How long the still stays up when the app does not leave (the browser did not open). - private static let stillTimeout: UInt64 = 2_000_000_000 - /// The longest the checkout is checked in the background. - private static let backgroundCheckLimit: TimeInterval = 25 - /// How much background time is kept to cover the screen and have iOS retake the snapshot. - private static let suspendLead: TimeInterval = 4 - private static let backgroundCheckInterval: UInt64 = 1_000_000_000 - /// How long the app keeps running once it asked iOS to retake the snapshot. - private static let snapshotLinger: UInt64 = 1_000_000_000 - /// How long a return by hand waits for a return link before showing the screen again. Some - /// apps hand the link over only after the app is active. - private static let revealGrace: UInt64 = 350_000_000 - private static let revealDuration: TimeInterval = 0.25 + /// How long each step waits. Tests shorten these. + struct Timing { + /// How long leaving waits for the page to draw the waiting screen. + var drawWait: UInt64 = 500_000_000 + /// How long the still stays up when the app does not leave (the browser did not open). + var stillTimeout: UInt64 = 2_000_000_000 + /// The longest the checkout is checked in the background. + var backgroundCheckLimit: TimeInterval = 25 + /// How much background time is kept to cover the screen and have iOS retake the snapshot. + var suspendLead: TimeInterval = 4 + var backgroundCheckInterval: UInt64 = 1_000_000_000 + /// How long the app keeps running once it asked iOS to retake the snapshot. + var snapshotLinger: UInt64 = 1_000_000_000 + /// How long a return by hand waits for a return link before showing the screen again. Some + /// apps hand the link over only after the app is active. + var revealGrace: UInt64 = 350_000_000 + var revealDuration: TimeInterval = 0.25 + } + + /// What the cover asks of iOS and the network. Tests stand these in. + struct Environment { + var applicationState: () -> UIApplication.State = { + UIApplication.sharedApplication?.applicationState ?? .active + } + var isSettled: (Watch) async -> Bool = CheckoutStatusCheck.isSettled + var makeStill: (UIView) -> UIView? = { $0.snapshotView(afterScreenUpdates: false) } + /// Nil when there is no application to ask for background time. + var beginBackgroundTask: (@escaping () -> Void) -> UIBackgroundTaskIdentifier? = { handler in + UIApplication.sharedApplication?.beginBackgroundTask( + withName: "Superwall checkout", + expirationHandler: handler + ) + } + var endBackgroundTask: (UIBackgroundTaskIdentifier) -> Void = { + UIApplication.sharedApplication?.endBackgroundTask($0) + } + var backgroundTimeRemaining: () -> TimeInterval = { + UIApplication.sharedApplication?.backgroundTimeRemaining ?? 0 + } + } + + private let timing: Timing + private let environment: Environment private weak var view: UIView? private weak var webView: WKWebView? @@ -81,9 +109,26 @@ final class TeleportReturnCover: NSObject { private var backgroundCheck: Task? private var backgroundTask: UIBackgroundTaskIdentifier = .invalid - init(view: UIView, webView: WKWebView) { + /// Whether a picture of the paywall is over the waiting screen. + var isCovering: Bool { + returnCover != nil + } + + /// The cover's opacity, for tests of the fade. + var coverAlpha: CGFloat? { + returnCover?.view.alpha + } + + init( + view: UIView, + webView: WKWebView, + timing: Timing = Timing(), + environment: Environment = Environment() + ) { self.view = view self.webView = webView + self.timing = timing + self.environment = environment super.init() let center = NotificationCenter.default // Called synchronously: iOS takes the snapshot as soon as these observers return. @@ -120,7 +165,7 @@ final class TeleportReturnCover: NSObject { #available(iOS 14.0, *), let view, let webView, - let still = view.snapshotView(afterScreenUpdates: false) + let still = environment.makeStill(view) else { open() return @@ -147,7 +192,7 @@ final class TeleportReturnCover: NSObject { } } Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: Self.drawWait) + try? await Task.sleep(nanoseconds: timing.drawWait) self?.open(id) } } @@ -177,8 +222,8 @@ final class TeleportReturnCover: NSObject { pendingOpen = nil pending.open() Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: Self.stillTimeout) - guard let self, UIApplication.sharedApplication?.applicationState == .active else { + try? await Task.sleep(nanoseconds: timing.stillTimeout) + guard let self, environment.applicationState() == .active else { return } self.removeLeavingStill() @@ -243,7 +288,7 @@ final class TeleportReturnCover: NSObject { tellPageOfReturn() return } - if UIApplication.sharedApplication?.applicationState == .active { + if environment.applicationState() == .active { uncover() } } @@ -257,12 +302,9 @@ final class TeleportReturnCover: NSObject { scope: .paywallViewController, message: "Checkout: checking the checkout while the app is in the background" ) - guard let application = UIApplication.sharedApplication else { - return - } - backgroundTask = application.beginBackgroundTask(withName: "Superwall checkout") { [weak self] in - // Out of time before the check finished: cover the screen while iOS still listens. iOS - // calls this on the main thread and expects the task ended before it returns, so no hop. + // Out of time before the check finished: cover the screen while iOS still listens. iOS + // calls this on the main thread and expects the task ended before it returns, so no hop. + guard let task = environment.beginBackgroundTask({ [weak self] in MainActor.assumeIsolated { guard let self else { return @@ -270,17 +312,20 @@ final class TeleportReturnCover: NSObject { self.coverBeforeSuspending() self.stopBackgroundCheck() } + }) else { + return } + backgroundTask = task // Leaves enough of the background time to cover the screen and have the snapshot retaken. - let remaining = application.backgroundTimeRemaining - let checkFor = min(Self.backgroundCheckLimit, max(0, remaining - Self.suspendLead)) + let remaining = environment.backgroundTimeRemaining() + let checkFor = min(timing.backgroundCheckLimit, max(0, remaining - timing.suspendLead)) backgroundCheck = Task { @MainActor [weak self] in let deadline = Date().addingTimeInterval(checkFor) while !Task.isCancelled, Date() < deadline { guard let watch = self?.watch else { break } - if await CheckoutStatusCheck.isSettled(watch) { + if await self?.environment.isSettled(watch) ?? false { guard !Task.isCancelled else { break } @@ -291,17 +336,17 @@ final class TeleportReturnCover: NSObject { ) self?.cover(.settled) self?.refreshSnapshot() - try? await Task.sleep(nanoseconds: Self.snapshotLinger) + try? await Task.sleep(nanoseconds: timing.snapshotLinger) self?.endBackgroundTask() return } - try? await Task.sleep(nanoseconds: Self.backgroundCheckInterval) + try? await Task.sleep(nanoseconds: timing.backgroundCheckInterval) } guard !Task.isCancelled else { return } self?.coverBeforeSuspending() - try? await Task.sleep(nanoseconds: Self.snapshotLinger) + try? await Task.sleep(nanoseconds: timing.snapshotLinger) self?.endBackgroundTask() } } @@ -331,7 +376,7 @@ final class TeleportReturnCover: NSObject { guard backgroundTask != .invalid else { return } - UIApplication.sharedApplication?.endBackgroundTask(backgroundTask) + environment.endBackgroundTask(backgroundTask) backgroundTask = .invalid } @@ -389,7 +434,7 @@ final class TeleportReturnCover: NSObject { } } Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: Self.drawWait) + try? await Task.sleep(nanoseconds: timing.drawWait) self?.finishUncovering(id) } } @@ -433,7 +478,7 @@ private extension TeleportReturnCover { let id = UUID() revealing = id Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: Self.revealGrace) + try? await Task.sleep(nanoseconds: timing.revealGrace) guard let self, self.revealing == id else { return } @@ -442,13 +487,13 @@ private extension TeleportReturnCover { guard let returnCover = self.returnCover, returnCover.reason == .suspending, - UIApplication.sharedApplication?.applicationState == .active + environment.applicationState() == .active else { return } // The cover is the paywall, so this reads as the waiting screen coming up over it. UIView.animate( - withDuration: Self.revealDuration, + withDuration: timing.revealDuration, delay: 0, options: [.curveEaseOut, .allowUserInteraction] ) { diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 30c2ac77f6..07294d9ff8 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -209,6 +209,7 @@ 4E078EFFD0C1992563021220 /* PurchaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D45DC0981EE9BBE3BF56C48 /* PurchaseController.swift */; }; 4E5F1AB421E91E102B6D4800 /* OnDeviceCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5283BA49E380740C34D78856 /* OnDeviceCaching.swift */; }; 4E7761949715C8BF8DEEF35C /* ReceiptLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A53CC571B7BC3F5AAD71BF /* ReceiptLogic.swift */; }; + 4ED5A9193F5672E254E8181F /* TeleportReturnCoverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 391C860C0057765BDC18BFC8 /* TeleportReturnCoverTests.swift */; }; 4EF50815E31F85400513F053 /* PresentationItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23F2FE294EBC63F81786A85 /* PresentationItems.swift */; }; 4EFA142D3D37B0564BDB52CB /* NetworkMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */; }; 4FE19D26711ECB7B2EE8806D /* TriggerRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481D47E5121C521DDA268609 /* TriggerRule.swift */; }; @@ -868,6 +869,7 @@ 35F538F901BAF97DDCA86F84 /* DeviceHelperMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelperMock.swift; sourceTree = ""; }; 368EF475049935105AF8154C /* Entitlement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entitlement.swift; sourceTree = ""; }; 37B17A8801A2A9454E66D892 /* ASN1Decoder+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ASN1Decoder+Utils.swift"; sourceTree = ""; }; + 391C860C0057765BDC18BFC8 /* TeleportReturnCoverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeleportReturnCoverTests.swift; sourceTree = ""; }; 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockReceiptData.swift; sourceTree = ""; }; 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2Migrator.swift; sourceTree = ""; }; 3A7BF742EBC950966662849F /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; @@ -2276,6 +2278,7 @@ A453816DBA43C08410D12DE6 /* PaywallViewControllerMock.swift */, 4BFFA527207A52EB7C70CAD4 /* PopupTransitionTests.swift */, B002FEEF20120D3A6B2AE923 /* SurveyManagerTests.swift */, + 391C860C0057765BDC18BFC8 /* TeleportReturnCoverTests.swift */, 3BE2C31A01D986D7FED5C4F4 /* Web View */, ); path = "View Controller"; @@ -3932,6 +3935,7 @@ 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */, 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */, 18D39CB7BCF324B44197735D /* TaskRetryingTests.swift in Sources */, + 4ED5A9193F5672E254E8181F /* TeleportReturnCoverTests.swift in Sources */, 14D8827BA663A0EFD64DEC2C /* TemplateLogicTests.swift in Sources */, 7D47BABD89CE33CDD78DFCC6 /* TestFileManager.swift in Sources */, 5338AD57C30507242FFC0A39 /* TestModeManagerTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift new file mode 100644 index 0000000000..8d73c3aff0 --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift @@ -0,0 +1,135 @@ +// +// TeleportReturnCoverTests.swift +// SuperwallKitTests +// + +import Testing +import UIKit +import WebKit +@testable import SuperwallKit + +/// Drives the cover through the app lifecycle the way iOS does, with notifications, and with the +/// checkout never settling, so every cover is the one put up just before suspension. +@MainActor +struct TeleportReturnCoverTests { + @MainActor + private final class Fixture { + let window: UIWindow + let view: UIView + let webView: WKWebView + let cover: TeleportReturnCover + var applicationState: UIApplication.State = .active + + init(timing: TeleportReturnCover.Timing) { + window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view = UIView(frame: window.bounds) + webView = WKWebView(frame: window.bounds) + window.addSubview(view) + window.isHidden = false + var state: UIApplication.State = .active + var environment = TeleportReturnCover.Environment() + environment.applicationState = { state } + environment.isSettled = { _ in false } + environment.makeStill = { _ in UIView() } + environment.beginBackgroundTask = { _ in UIBackgroundTaskIdentifier(rawValue: 1) } + environment.endBackgroundTask = { _ in } + environment.backgroundTimeRemaining = { 30 } + cover = TeleportReturnCover( + view: view, + webView: webView, + timing: timing, + environment: environment + ) + setState = { state = $0 } + } + + private var setState: (UIApplication.State) -> Void = { _ in } + + func set(_ newState: UIApplication.State) { + setState(newState) + } + } + + private static let watch = TeleportReturnCover.Watch( + teleportId: "tp_1", + teleportStatusUrl: URL(string: "https://example.com/teleport")!, + checkoutStatusUrl: URL(string: "https://example.com/checkout")!, + publicApiKey: "pk_test" + ) + + private static var fastTiming: TeleportReturnCover.Timing { + var timing = TeleportReturnCover.Timing() + timing.drawWait = 50_000_000 + timing.stillTimeout = 50_000_000 + timing.backgroundCheckLimit = 0 + timing.suspendLead = 0 + timing.snapshotLinger = 10_000_000 + timing.revealGrace = 100_000_000 + timing.revealDuration = 0.4 + return timing + } + + private func post(_ name: Notification.Name, userInfo: [AnyHashable: Any]? = nil) { + NotificationCenter.default.post(name: name, object: nil, userInfo: userInfo) + } + + private func sleep(_ seconds: Double) async { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + + /// Leaves for the browser and comes back to a suspended app: the cover put up before + /// suspension is over the waiting screen. + private func makeSuspendedCover() async -> Fixture { + let fixture = Fixture(timing: Self.fastTiming) + fixture.cover.startWatching(Self.watch) + fixture.cover.leave(drawingWaitingScreen: true) {} + await sleep(0.1) + fixture.set(.background) + post(UIApplication.didEnterBackgroundNotification) + await sleep(0.1) + #expect(fixture.cover.isCovering) + return fixture + } + + @Test("An activation interrupted during the grace period does not stop the next one revealing") + func testRevealAfterInterruptedGrace() async { + let fixture = await makeSuspendedCover() + + // The app becomes active and then inactive again before the grace period ends. + fixture.set(.active) + post(UIApplication.didBecomeActiveNotification) + fixture.set(.inactive) + await sleep(0.2) + #expect(fixture.cover.isCovering) + + fixture.set(.active) + post(UIApplication.didBecomeActiveNotification) + await sleep(0.2 + Self.fastTiming.revealDuration + 0.2) + + #expect(!fixture.cover.isCovering) + } + + @Test("A return link during the fade keeps the cover whole until the page removes the screen") + func testReturnLinkDuringFade() async { + let fixture = await makeSuspendedCover() + + fixture.set(.active) + post(UIApplication.didBecomeActiveNotification) + await sleep(0.2) + #expect(fixture.cover.isCovering) + + post(.superwallReturnLinkOpened, userInfo: ["reason": "purchased"]) + #expect(fixture.cover.isCovering) + #expect(fixture.cover.coverAlpha == 1) + + await sleep(0.15) + #expect(!fixture.cover.isCovering) + } + + @Test("A return link with no cover to take off still reaches the page") + func testReturnLinkWithoutCover() async { + let fixture = Fixture(timing: Self.fastTiming) + post(.superwallReturnLinkOpened, userInfo: ["reason": "closed"]) + #expect(!fixture.cover.isCovering) + } +} From 791ae622fb376ebf2ef366674b16c0416c1f8c4b Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 8 Oct 2026 18:27:34 +0200 Subject: [PATCH 06/13] test: give each return cover test its own notification center --- .../View Controller/TeleportReturnCover.swift | 4 ++- .../TeleportReturnCoverTests.swift | 29 +++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift index badb0e5e04..9f50ddb30c 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -89,6 +89,8 @@ final class TeleportReturnCover: NSObject { var backgroundTimeRemaining: () -> TimeInterval = { UIApplication.sharedApplication?.backgroundTimeRemaining ?? 0 } + /// Where the app lifecycle and return link notifications arrive. + var notificationCenter: NotificationCenter = .default } private let timing: Timing @@ -130,7 +132,7 @@ final class TeleportReturnCover: NSObject { self.timing = timing self.environment = environment super.init() - let center = NotificationCenter.default + let center = environment.notificationCenter // Called synchronously: iOS takes the snapshot as soon as these observers return. center.addObserver( self, diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift index 8d73c3aff0..f5534fc444 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift @@ -18,7 +18,8 @@ struct TeleportReturnCoverTests { let view: UIView let webView: WKWebView let cover: TeleportReturnCover - var applicationState: UIApplication.State = .active + /// This fixture's own center, so no other test's cover hears its notifications. + let notificationCenter = NotificationCenter() init(timing: TeleportReturnCover.Timing) { window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) @@ -34,6 +35,7 @@ struct TeleportReturnCoverTests { environment.beginBackgroundTask = { _ in UIBackgroundTaskIdentifier(rawValue: 1) } environment.endBackgroundTask = { _ in } environment.backgroundTimeRemaining = { 30 } + environment.notificationCenter = notificationCenter cover = TeleportReturnCover( view: view, webView: webView, @@ -69,8 +71,12 @@ struct TeleportReturnCoverTests { return timing } - private func post(_ name: Notification.Name, userInfo: [AnyHashable: Any]? = nil) { - NotificationCenter.default.post(name: name, object: nil, userInfo: userInfo) + private func post( + _ name: Notification.Name, + to fixture: Fixture, + userInfo: [AnyHashable: Any]? = nil + ) { + fixture.notificationCenter.post(name: name, object: nil, userInfo: userInfo) } private func sleep(_ seconds: Double) async { @@ -85,7 +91,7 @@ struct TeleportReturnCoverTests { fixture.cover.leave(drawingWaitingScreen: true) {} await sleep(0.1) fixture.set(.background) - post(UIApplication.didEnterBackgroundNotification) + post(UIApplication.didEnterBackgroundNotification, to: fixture) await sleep(0.1) #expect(fixture.cover.isCovering) return fixture @@ -97,13 +103,13 @@ struct TeleportReturnCoverTests { // The app becomes active and then inactive again before the grace period ends. fixture.set(.active) - post(UIApplication.didBecomeActiveNotification) + post(UIApplication.didBecomeActiveNotification, to: fixture) fixture.set(.inactive) await sleep(0.2) #expect(fixture.cover.isCovering) fixture.set(.active) - post(UIApplication.didBecomeActiveNotification) + post(UIApplication.didBecomeActiveNotification, to: fixture) await sleep(0.2 + Self.fastTiming.revealDuration + 0.2) #expect(!fixture.cover.isCovering) @@ -114,22 +120,15 @@ struct TeleportReturnCoverTests { let fixture = await makeSuspendedCover() fixture.set(.active) - post(UIApplication.didBecomeActiveNotification) + post(UIApplication.didBecomeActiveNotification, to: fixture) await sleep(0.2) #expect(fixture.cover.isCovering) - post(.superwallReturnLinkOpened, userInfo: ["reason": "purchased"]) + post(.superwallReturnLinkOpened, to: fixture, userInfo: ["reason": "purchased"]) #expect(fixture.cover.isCovering) #expect(fixture.cover.coverAlpha == 1) await sleep(0.15) #expect(!fixture.cover.isCovering) } - - @Test("A return link with no cover to take off still reaches the page") - func testReturnLinkWithoutCover() async { - let fixture = Fixture(timing: Self.fastTiming) - post(.superwallReturnLinkOpened, userInfo: ["reason": "closed"]) - #expect(!fixture.cover.isCovering) - } } From 241a965a270ad17ee1f0d5020bbaf65dea0ef17e Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 13 Oct 2026 15:40:40 +0200 Subject: [PATCH 07/13] refactor: keep teleport generic and drop return-link hosts the backend never sends --- CHANGELOG.md | 4 +- .../TrackableSuperwallEvent.swift | 6 +- .../Superwall Placement/SuperwallEvent.swift | 4 +- .../SuperwallEventObjc.swift | 4 +- Sources/SuperwallKit/DeepLinkRouter.swift | 16 ++--- .../PaywallViewController.swift | 2 +- .../View Controller/TeleportReturnCover.swift | 64 ++++++++++--------- .../Message Handling/PaywallMessage.swift | 3 +- .../PaywallMessageHandler.swift | 6 +- .../SubscriptionsApiEnvironmentScript.swift | 2 +- .../Internal Tracking/TrackTests.swift | 4 +- .../DeepLink/DeepLinkRouterTests.swift | 10 +-- .../PaywallMessageHandlerTests.swift | 32 ++++++++++ 13 files changed, 95 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d4512b0a..0e74355b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ 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://.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:.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 return links, `yourscheme://superwall/return` and `https://.superwall.link/app-link/superwall/return` To open your app from a teleported web flow without the Open in App alert, add `applinks:.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 a web flow, such as 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. - Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`, each an `AdConsentStatus` of `.granted` or `.denied`), which Superwall passes on with the conversions it uploads to Google Ads and Meta. If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent banner when GDPR applies, and otherwise defaults to granted; apps with users in the EEA, UK or Switzerland that don't use a TCF banner should set it from their consent flow. Where the values came from is reported in the new `adConsentSource` device attribute (`developer`, `tcf` or `default`). The values are reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted, unless you set `adConsent` yourself. diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index d96a49d02a..b1bcb447b4 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -1006,7 +1006,7 @@ enum InternalSuperwallEvent { } func getSuperwallParameters() async -> [String: Any] { - // No URL: a checkout page's path carries its sealed teleport. + // No URL: a teleport link's path carries its sealed teleport. return await paywallInfo.placementParams() } } @@ -1014,8 +1014,8 @@ enum InternalSuperwallEvent { 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? + /// Why the web flow sent the user back, as it put it in the link, such as `purchased` or `closed`. + let reason: String? var superwallEvent: SuperwallEvent { return .teleportReturn(paywallInfo: paywallInfo) diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index e6ad6d63a4..8d47a33197 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -353,10 +353,10 @@ public enum SuperwallEvent { /// When a Stripe checkout session fails. case stripeCheckoutFail(paywallInfo: PaywallInfo) - /// When a paywall opens its checkout page in the browser. + /// When a paywall teleports the user to a web flow in the browser, such as its checkout page. case teleportOpen(paywallInfo: PaywallInfo) - /// When a return link brings the user back to the app from a checkout page. + /// When a return link brings the user back to the app from a teleported web flow. /// /// `paywallInfo` is the presented paywall's, if one is presented. case teleportReturn(paywallInfo: PaywallInfo?) diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift index a8ba6cf707..f2d5e0a107 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -274,10 +274,10 @@ 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. + /// When a paywall teleports the user to a web flow in the browser, such as its checkout page. case teleportOpen - /// When a return link brings the user back to the app from a checkout page. + /// When a return link brings the user back to the app from a teleported web flow. case teleportReturn public init(event: SuperwallEvent) { diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index 8c3ab167b3..7591189359 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -100,7 +100,7 @@ final class DeepLinkRouter { } private func handleReturnLink(_ url: URL) { - // The paywall that opened the checkout takes its waiting screen out of sight at once. + // The paywall that teleported takes its waiting screen out of sight at once. let userInfo = url.superwallReturnReason.map { ["reason": $0] } if Thread.isMainThread { NotificationCenter.default.post( @@ -218,10 +218,10 @@ 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 + /// Whether this link brings the user back to the app from a teleported + /// Superwall web flow, such as a paywall's checkout page. Matches /// `scheme://superwall/return` and - /// `https://.superwall.app/app-link/superwall/return`. + /// `https://.superwall.link/app-link/superwall/return`. var isSuperwallReturnLink: Bool { if scheme != "http", scheme != "https", @@ -231,9 +231,7 @@ extension URL { } if let host, - host.hasSuffix(".superwall.link") - || host.hasSuffix(".superwall.app") - || host.hasSuffix(".superwallapp.dev"), + host.hasSuffix(".superwall.link"), path == "/app-link/superwall/return" { return true } @@ -241,8 +239,8 @@ extension URL { 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. + /// Why the web flow sent the user back, when it says, such as `purchased` after paying on a + /// checkout page or `closed` when they closed it instead. var superwallReturnReason: String? { URLComponents(url: self, resolvingAgainstBaseURL: false)? .queryItems? diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index d91736ce48..015d019e32 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -159,7 +159,7 @@ 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. + /// What the user sees of a teleport'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 diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift index 9f50ddb30c..5471c14d47 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -12,41 +12,44 @@ extension Notification.Name { static let superwallReturnLinkOpened = Notification.Name("SuperwallReturnLinkOpened") } -/// Decides what the shopper sees of a checkout page's waiting screen as they leave the app for -/// the browser and come back. +/// Decides what the user sees of a teleport's waiting screen as they leave the app for the +/// browser and come back. /// /// The waiting screen has to be in place when they switch back by hand, so it must already be in /// the snapshot iOS takes when the app leaves. But iOS also shows that snapshot while the app comes -/// back, however it comes back, and a shopper who returns through the return link, having paid or -/// left the checkout, must never see the screen. A webview paints nothing in the background, so: +/// back, however it comes back, and a user who returns through the return link, having finished or +/// left the web flow, must never see the screen. A webview paints nothing in the background, so: /// - leaving, the page draws the screen under a still of the paywall, which comes off once the app /// is in the background, so the app switcher shows the screen; -/// - in the background, where the page cannot run, the checkout is checked here. Once it is paid -/// or expired, a picture of the paywall without the screen covers it and iOS retakes the -/// snapshot; +/// - in the background, where the page cannot run, the teleport is checked here. Once it is +/// settled, a picture of the paywall without the screen covers it and iOS retakes the snapshot; /// - just before iOS suspends the app, nothing settled yet, it covers the screen the same way. A /// return link can arrive at any time after that, and iOS can no longer be asked to retake the /// snapshot then. The app switcher shows the paywall from then on; /// - a return link covers it before the first frame, whatever else happened. /// Back in the app, through the return link or settled, the page removes the screen and the cover -/// comes off. Back by hand with the checkout still open, the cover fades and shows the screen. +/// comes off. Back by hand with the teleport still open, the cover fades and shows the screen. +/// +/// The `showCheckoutWaitingScreen` and `hideCheckoutWaitingScreen` page functions keep the names +/// paywall.js gave them; they serve any teleport. @MainActor final class TeleportReturnCover: NSObject { - /// What the page hands over to check its checkout, while the waiting screen is up. - struct Watch: Decodable, Equatable { + /// What the page hands over to check its teleport while the waiting screen is up. + struct Watch: Equatable { let teleportId: String let teleportStatusUrl: URL - let checkoutStatusUrl: URL + /// Where a checkout the teleport led to is read. Nil for a web flow without a checkout. + let checkoutStatusUrl: URL? let publicApiKey: String } /// Why the waiting screen is covered. private enum Reason { - /// The checkout was paid or expired while the app was in the background. + /// The teleport settled while the app was in the background. case settled /// The return link opened the app. case returnLink - /// The app was about to be suspended. The checkout may still be open. + /// The app was about to be suspended. The teleport may still be open. case suspending } @@ -56,7 +59,7 @@ final class TeleportReturnCover: NSObject { var drawWait: UInt64 = 500_000_000 /// How long the still stays up when the app does not leave (the browser did not open). var stillTimeout: UInt64 = 2_000_000_000 - /// The longest the checkout is checked in the background. + /// The longest the teleport is checked in the background. var backgroundCheckLimit: TimeInterval = 25 /// How much background time is kept to cover the screen and have iOS retake the snapshot. var suspendLead: TimeInterval = 4 @@ -74,12 +77,12 @@ final class TeleportReturnCover: NSObject { var applicationState: () -> UIApplication.State = { UIApplication.sharedApplication?.applicationState ?? .active } - var isSettled: (Watch) async -> Bool = CheckoutStatusCheck.isSettled + var isSettled: (Watch) async -> Bool = TeleportStatusCheck.isSettled var makeStill: (UIView) -> UIView? = { $0.snapshotView(afterScreenUpdates: false) } /// Nil when there is no application to ask for background time. var beginBackgroundTask: (@escaping () -> Void) -> UIBackgroundTaskIdentifier? = { handler in UIApplication.sharedApplication?.beginBackgroundTask( - withName: "Superwall checkout", + withName: "Superwall teleport", expirationHandler: handler ) } @@ -208,7 +211,7 @@ final class TeleportReturnCover: NSObject { stopBackgroundCheck() } - /// The paywall is gone: nothing of a checkout stays over it. + /// The paywall is gone: nothing of a teleport stays over it. func reset() { removeLeavingStill() removeCover() @@ -270,15 +273,15 @@ final class TeleportReturnCover: NSObject { @objc private func returnLinkOpened(_ notification: Notification) { returnReason = notification.userInfo?["reason"] as? String guard watch != nil || returnCover != nil else { - // No waiting screen to cover, but the page still has to know the shopper is back: it - // checks the checkout for a while, then treats an unpaid one as abandoned. + // No waiting screen to cover, but the page still has to know the user is back: it + // checks the teleport for a while, then treats an unfinished one as abandoned. tellPageOfReturn() return } Logger.debug( logLevel: .debug, scope: .paywallViewController, - message: "Checkout: return link opened, covering the waiting screen" + message: "Teleport: return link opened, covering the waiting screen" ) if let returnCover { self.returnCover = (returnCover.view, .returnLink) @@ -302,7 +305,7 @@ final class TeleportReturnCover: NSObject { Logger.debug( logLevel: .debug, scope: .paywallViewController, - message: "Checkout: checking the checkout while the app is in the background" + message: "Teleport: checking the teleport while the app is in the background" ) // Out of time before the check finished: cover the screen while iOS still listens. iOS // calls this on the main thread and expects the task ended before it returns, so no hop. @@ -334,7 +337,7 @@ final class TeleportReturnCover: NSObject { Logger.debug( logLevel: .debug, scope: .paywallViewController, - message: "Checkout: settled in the background, covering the waiting screen" + message: "Teleport: settled in the background, covering the waiting screen" ) self?.cover(.settled) self?.refreshSnapshot() @@ -362,7 +365,7 @@ final class TeleportReturnCover: NSObject { Logger.debug( logLevel: .debug, scope: .paywallViewController, - message: "Checkout: suspending with the checkout open, covering the waiting screen" + message: "Teleport: suspending with the teleport open, covering the waiting screen" ) cover(.suspending) refreshSnapshot() @@ -449,7 +452,7 @@ final class TeleportReturnCover: NSObject { Logger.debug( logLevel: .debug, scope: .paywallViewController, - message: "Checkout: waiting screen removed, cover off" + message: "Teleport: waiting screen removed, cover off" ) } } @@ -471,7 +474,7 @@ private extension TeleportReturnCover { returnReason = nil } - /// Back by hand with the checkout still open: the waiting screen is still the right thing to + /// Back by hand with the teleport still open: the waiting screen is still the right thing to /// show, so the cover fades off it, unless a return link turns up first. func revealAfterGrace() { guard revealing == nil, uncovering == nil else { @@ -516,10 +519,10 @@ private extension TeleportReturnCover { } } -/// Reads a checkout's status from the background, where the paywall page cannot run. -enum CheckoutStatusCheck { - /// Paid or expired: the waiting screen goes, and the shopper likely comes back through the - /// return link. +/// Reads a teleport's status from the background, where the paywall page cannot run. +enum TeleportStatusCheck { + /// Completed or expired, or the checkout it led to paid: the waiting screen goes, and the user + /// likely comes back through the return link. static func isSettled(_ watch: TeleportReturnCover.Watch) async -> Bool { guard let teleport = await post( watch.teleportStatusUrl, @@ -536,9 +539,10 @@ enum CheckoutStatusCheck { break } guard + let checkoutStatusUrl = watch.checkoutStatusUrl, let checkoutId = teleport["checkoutContextId"] as? String, let checkout = await post( - watch.checkoutStatusUrl, + checkoutStatusUrl, body: ["checkoutId": checkoutId], publicApiKey: watch.publicApiKey ) 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 4866fb7c65..21c1505790 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 @@ -220,13 +220,12 @@ enum PaywallMessage: Decodable, Equatable { case .teleportWatchStart: if let teleportId = try? values.decode(String.self, forKey: .teleportId), let teleportStatusUrl = try? values.decode(URL.self, forKey: .teleportStatusUrl), - let checkoutStatusUrl = try? values.decode(URL.self, forKey: .checkoutStatusUrl), let publicApiKey = try? values.decode(String.self, forKey: .publicApiKey) { self = .teleportWatchStart( TeleportReturnCover.Watch( teleportId: teleportId, teleportStatusUrl: teleportStatusUrl, - checkoutStatusUrl: checkoutStatusUrl, + checkoutStatusUrl: try? values.decodeIfPresent(URL.self, forKey: .checkoutStatusUrl), publicApiKey: publicApiKey ) ) 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 8e6bb33dcf..2dddc565f5 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 @@ -302,7 +302,7 @@ final class PaywallMessageHandler: WebEventDelegate { /// Called when a new page replaces the old one, and when the SDK starts /// loading a page over one that is still on screen. The old page isn't /// told about the paywall opening or closing, and the new one is told the - /// paywall is open once it pings. A checkout the old page was watching ends + /// paywall is open once it pings. A teleport the old page was watching ends /// with it, since the new page will never end it. func documentWillLoad() { isDocumentReady = false @@ -547,8 +547,8 @@ final class PaywallMessageHandler: WebEventDelegate { private func openUrlInSafari( _ url: URL, - drawsWaitingScreen: Bool = false, - isTeleport: Bool = false + drawsWaitingScreen: Bool, + isTeleport: Bool ) { detectHiddenPaywallEvent( "openUrlInSafari", diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift index 89e7630ea7..688adbc8d9 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SubscriptionsApiEnvironmentScript.swift @@ -10,7 +10,7 @@ import Foundation /// A native paywall is served from the same CDN host in every environment, so the /// page cannot infer the environment from its own URL the way a hosted web paywall /// can. Without this it falls back to production, which silently breaks a -/// `.developer` or `.local` SDK testing a checkout page against that stack. +/// `.developer` or `.local` SDK testing a teleport or web checkout against that stack. enum SubscriptionsApiEnvironmentScript { /// The script source, or `nil` when the environment is production or unknown and /// paywall.js should keep its own default. diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift index 145797048d..744dade5f6 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift @@ -2853,7 +2853,7 @@ struct TrackingTests { @Test func teleportReturn_withPresentedPaywall() async { let paywallInfo: PaywallInfo = .stub() let result = await Superwall.shared.track( - InternalSuperwallEvent.TeleportReturn(paywallInfo: paywallInfo)) + InternalSuperwallEvent.TeleportReturn(paywallInfo: paywallInfo, reason: nil)) #expect(result.parameters.audienceFilterParams["$app_session_id"] != nil) #expect(result.parameters.audienceFilterParams["$is_standard_event"] as! Bool) #expect( @@ -2866,7 +2866,7 @@ struct TrackingTests { @Test func teleportReturn_withoutPresentedPaywall() async { let result = await Superwall.shared.track( - InternalSuperwallEvent.TeleportReturn(paywallInfo: nil)) + InternalSuperwallEvent.TeleportReturn(paywallInfo: nil, reason: nil)) #expect(result.parameters.audienceFilterParams["$app_session_id"] != nil) #expect( result.parameters.audienceFilterParams["$event_name"] as! String == "teleport_return") diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 577c1a3853..a25b6fc9cc 100644 --- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift +++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift @@ -189,8 +189,6 @@ struct DeepLinkRouterTests { "myapp://superwall/return", "myapp://superwall/return?source=checkout", "https://myapp.superwall.link/app-link/superwall/return", - "https://myapp.superwall.app/app-link/superwall/return", - "https://myapp.superwallapp.dev/app-link/superwall/return", ] ) func isSuperwallReturnLink_matches(link: String) { @@ -207,8 +205,10 @@ struct DeepLinkRouterTests { "myapp://other/return", "https://superwall/return", "https://example.com/app-link/superwall/return", - "https://myapp.superwall.app/superwall/return", - "https://myapp.superwall.app/app-link/superwall/other", + "https://myapp.superwall.link/superwall/return", + "https://myapp.superwall.link/app-link/superwall/other", + // The backend only sends return links on superwall.link. + "https://myapp.superwall.app/app-link/superwall/return", ] ) func isSuperwallReturnLink_ignores(link: String) { @@ -220,7 +220,7 @@ struct DeepLinkRouterTests { #expect(DeepLinkRouter.storeDeepLink(URL(string: "myapp://superwall/return")!) == true) #expect( DeepLinkRouter.storeDeepLink( - URL(string: "https://myapp.superwall.app/app-link/superwall/return")! + URL(string: "https://myapp.superwall.link/app-link/superwall/return")! ) == true ) } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index f6875658b9..acc632def8 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -967,6 +967,38 @@ struct PaywallMessageHandlerTests { ]) } + @Test + func decodeTeleportWatch_withoutACheckout() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "eventName": "teleport_watch_start", + "teleport_id": "v1:teleport", + "teleport_status_url": "https://subs.example.com/teleport/status", + "public_api_key": "pk_test" + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + + #expect(wrapped.payload.messages == [ + .teleportWatchStart( + TeleportReturnCover.Watch( + teleportId: "v1:teleport", + teleportStatusUrl: URL(string: "https://subs.example.com/teleport/status")!, + checkoutStatusUrl: nil, + publicApiKey: "pk_test" + ) + ), + ]) + } + // MARK: - Haptic Feedback Message Decoding Tests @Test From c89132633d781b5ba68f618dd2b45036c659490d Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 13 Oct 2026 16:17:06 +0200 Subject: [PATCH 08/13] fix: pass the visionOS open-url call its arguments and isolate the return cover's environment --- .../View Controller/TeleportReturnCover.swift | 22 ++++++++++--------- .../PaywallMessageHandler.swift | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift index 5471c14d47..96fb1a46d6 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -74,22 +74,24 @@ final class TeleportReturnCover: NSObject { /// What the cover asks of iOS and the network. Tests stand these in. struct Environment { - var applicationState: () -> UIApplication.State = { + var applicationState: @MainActor () -> UIApplication.State = { UIApplication.sharedApplication?.applicationState ?? .active } var isSettled: (Watch) async -> Bool = TeleportStatusCheck.isSettled - var makeStill: (UIView) -> UIView? = { $0.snapshotView(afterScreenUpdates: false) } + var makeStill: @MainActor (UIView) -> UIView? = { $0.snapshotView(afterScreenUpdates: false) } /// Nil when there is no application to ask for background time. - var beginBackgroundTask: (@escaping () -> Void) -> UIBackgroundTaskIdentifier? = { handler in + var beginBackgroundTask: @MainActor ( + @escaping @Sendable () -> Void + ) -> UIBackgroundTaskIdentifier? = { handler in UIApplication.sharedApplication?.beginBackgroundTask( withName: "Superwall teleport", expirationHandler: handler ) } - var endBackgroundTask: (UIBackgroundTaskIdentifier) -> Void = { + var endBackgroundTask: @MainActor (UIBackgroundTaskIdentifier) -> Void = { UIApplication.sharedApplication?.endBackgroundTask($0) } - var backgroundTimeRemaining: () -> TimeInterval = { + var backgroundTimeRemaining: @MainActor () -> TimeInterval = { UIApplication.sharedApplication?.backgroundTimeRemaining ?? 0 } /// Where the app lifecycle and return link notifications arrive. @@ -196,7 +198,7 @@ final class TeleportReturnCover: NSObject { self?.open(id) } } - Task { @MainActor [weak self] in + Task { @MainActor [weak self, timing] in try? await Task.sleep(nanoseconds: timing.drawWait) self?.open(id) } @@ -226,7 +228,7 @@ final class TeleportReturnCover: NSObject { } pendingOpen = nil pending.open() - Task { @MainActor [weak self] in + Task { @MainActor [weak self, timing] in try? await Task.sleep(nanoseconds: timing.stillTimeout) guard let self, environment.applicationState() == .active else { return @@ -324,7 +326,7 @@ final class TeleportReturnCover: NSObject { // Leaves enough of the background time to cover the screen and have the snapshot retaken. let remaining = environment.backgroundTimeRemaining() let checkFor = min(timing.backgroundCheckLimit, max(0, remaining - timing.suspendLead)) - backgroundCheck = Task { @MainActor [weak self] in + backgroundCheck = Task { @MainActor [weak self, timing] in let deadline = Date().addingTimeInterval(checkFor) while !Task.isCancelled, Date() < deadline { guard let watch = self?.watch else { @@ -438,7 +440,7 @@ final class TeleportReturnCover: NSObject { } } } - Task { @MainActor [weak self] in + Task { @MainActor [weak self, timing] in try? await Task.sleep(nanoseconds: timing.drawWait) self?.finishUncovering(id) } @@ -482,7 +484,7 @@ private extension TeleportReturnCover { } let id = UUID() revealing = id - Task { @MainActor [weak self] in + Task { @MainActor [weak self, timing] in try? await Task.sleep(nanoseconds: timing.revealGrace) guard let self, self.revealing == id else { 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 2dddc565f5..9a77cef2c2 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 @@ -533,7 +533,7 @@ final class PaywallMessageHandler: WebEventDelegate { private func openUrl(_ url: URL) { #if os(visionOS) - openUrlInSafari(url) + openUrlInSafari(url, drawsWaitingScreen: false, isTeleport: false) #else detectHiddenPaywallEvent( "openUrl", From 7f00a022a76f3bdd0f786e21792114c73190cc01 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 13 Oct 2026 16:29:30 +0200 Subject: [PATCH 09/13] docs: restore the period in the return links changelog entry [skip ci] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e74355b58..f0281a49be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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://.superwall.link/app-link/superwall/return` To open your app from a teleported web flow without the Open in App alert, add `applinks:.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 return links, `yourscheme://superwall/return` and `https://.superwall.link/app-link/superwall/return`. To open your app from a teleported web flow without the Open in App alert, add `applinks:.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 a web flow, such as 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. From 2ca0e313caca68e267ee7a477c689193fbe9851b Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 13 Oct 2026 16:40:44 +0200 Subject: [PATCH 10/13] refactor: call the teleport-named page functions, falling back to the checkout names --- .../View Controller/TeleportReturnCover.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift index 96fb1a46d6..c55c783353 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -30,10 +30,20 @@ extension Notification.Name { /// Back in the app, through the return link or settled, the page removes the screen and the cover /// comes off. Back by hand with the teleport still open, the cover fades and shows the screen. /// -/// The `showCheckoutWaitingScreen` and `hideCheckoutWaitingScreen` page functions keep the names -/// paywall.js gave them; they serve any teleport. +/// The page's `showTeleportWaitingScreen` and `handleTeleportReturn` were first named +/// `showCheckoutWaitingScreen` and `hideCheckoutWaitingScreen`; pages built before the rename only +/// have the old names, so each call falls back to them. @MainActor final class TeleportReturnCover: NSObject { + private static let showWaitingScreenScript = """ + return await (window.app?.showTeleportWaitingScreen?.() + ?? window.app?.showCheckoutWaitingScreen?.()) ?? false + """ + private static let returnScript = """ + return await (window.app?.handleTeleportReturn?.({ reason }) + ?? window.app?.hideCheckoutWaitingScreen?.({ reason })) ?? false + """ + /// What the page hands over to check its teleport while the waiting screen is up. struct Watch: Equatable { let teleportId: String @@ -189,7 +199,7 @@ final class TeleportReturnCover: NSObject { let id = UUID() pendingOpen = (id, open) webView.callAsyncJavaScript( - "return await window.app?.showCheckoutWaitingScreen?.() ?? false", + Self.showWaitingScreenScript, arguments: [:], in: nil, in: .page @@ -430,7 +440,7 @@ final class TeleportReturnCover: NSObject { uncovering = id if #available(iOS 14.0, *), let webView { webView.callAsyncJavaScript( - "return await window.app?.hideCheckoutWaitingScreen?.({ reason }) ?? false", + Self.returnScript, arguments: ["reason": returnReason ?? ""], in: nil, in: .page @@ -467,7 +477,7 @@ private extension TeleportReturnCover { return } webView.callAsyncJavaScript( - "return await window.app?.hideCheckoutWaitingScreen?.({ reason }) ?? false", + Self.returnScript, arguments: ["reason": returnReason ?? ""], in: nil, in: .page, From fb801356a574d499692f401b62f76ee71e5f344d Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 13 Oct 2026 16:58:59 +0200 Subject: [PATCH 11/13] test: cover the teleport page functions and their fallback to the checkout names --- .../View Controller/TeleportReturnCover.swift | 4 +- .../TeleportReturnCoverTests.swift | 71 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift index c55c783353..56f59c2a7b 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -35,11 +35,11 @@ extension Notification.Name { /// have the old names, so each call falls back to them. @MainActor final class TeleportReturnCover: NSObject { - private static let showWaitingScreenScript = """ + static let showWaitingScreenScript = """ return await (window.app?.showTeleportWaitingScreen?.() ?? window.app?.showCheckoutWaitingScreen?.()) ?? false """ - private static let returnScript = """ + static let returnScript = """ return await (window.app?.handleTeleportReturn?.({ reason }) ?? window.app?.hideCheckoutWaitingScreen?.({ reason })) ?? false """ diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift index f5534fc444..c89c4f3474 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift @@ -131,4 +131,75 @@ struct TeleportReturnCoverTests { await sleep(0.15) #expect(!fixture.cover.isCovering) } + + @Test( + "The page functions are called by their teleport names, or their checkout names on older pages", + arguments: [ + ("showTeleportWaitingScreen", "handleTeleportReturn"), + ("showCheckoutWaitingScreen", "hideCheckoutWaitingScreen"), + ] + ) + func pageFunctions(show: String, handleReturn: String) async throws { + let webView = try await loadedWebView() + _ = try await webView.callAsyncJavaScript( + """ + window.calls = []; + window.app = { + [show]: async () => { window.calls.push("show"); return true }, + [handleReturn]: async (options) => { window.calls.push("return:" + options.reason); return true }, + }; + """, + arguments: ["show": show, "handleReturn": handleReturn], + contentWorld: .page + ) + + let shown = try await webView.callAsyncJavaScript( + TeleportReturnCover.showWaitingScreenScript, + contentWorld: .page + ) + let returned = try await webView.callAsyncJavaScript( + TeleportReturnCover.returnScript, + arguments: ["reason": "purchased"], + contentWorld: .page + ) + let calls = try await webView.callAsyncJavaScript( + "return window.calls", + contentWorld: .page + ) as? [String] + + #expect(shown as? Bool == true) + #expect(returned as? Bool == true) + #expect(calls == ["show", "return:purchased"]) + } + + @Test("A page without the functions is answered with false") + func pageWithoutFunctions() async throws { + let webView = try await loadedWebView() + + let shown = try await webView.callAsyncJavaScript( + TeleportReturnCover.showWaitingScreenScript, + contentWorld: .page + ) + let returned = try await webView.callAsyncJavaScript( + TeleportReturnCover.returnScript, + arguments: ["reason": ""], + contentWorld: .page + ) + + #expect(shown as? Bool == false) + #expect(returned as? Bool == false) + } + + /// A web view whose page has loaded. The blank page before it already reads as complete, so the + /// wait is for an element only the loaded page has. + private func loadedWebView() async throws -> WKWebView { + let webView = WKWebView() + webView.loadHTMLString("", baseURL: nil) + while try await webView.evaluateJavaScript( + "document.getElementById('loaded') !== null" + ) as? Bool != true { + try await Task.sleep(nanoseconds: 10_000_000) + } + return webView + } } From 2ceba50c10bde8e9faa405259427aa668b65dea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:11:37 +0200 Subject: [PATCH 12/13] fix: address review findings on teleport and web purchase completion - Leave a close for the next paywall as it is after a purchase, so the next paywall keeps the presentation's state. - Forget a purchase when the next presentation begins rather than when the view disappears, so a sheet swiped away after a purchase reports it. - Only stop an older checkout's poll for a newer checkout that was reported complete, not one that was only started. - Take the spinner down on a repeated checkout complete unless the success alert is up. - Call one page function, picking the teleport name first, so a page with both names doesn't have both called. - Wait for a late return link before telling the page about a settled teleport, so it hears the link's reason. - Only let a still's timeout remove that still. - Keep the presentation id that the fetching request's response events carry. - Drop iOS 14 availability checks, use URLSession's async data(for:), and reuse waitForActiveStripePoll. Co-Authored-By: Claude Opus 5.5 --- .../Request/PaywallRequestManager.swift | 10 +- .../PaywallViewController.swift | 14 +-- .../View Controller/TeleportReturnCover.swift | 69 ++++++++----- .../Web/WebEntitlementRedeemer.swift | 34 ++++--- .../TeleportReturnCoverTests.swift | 98 ++++++++++++++++++- .../Web/WebEntitlementRedeemerTests.swift | 60 +++++++++++- 6 files changed, 236 insertions(+), 49 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift index 2d4cade1aa..4731c5d0dc 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift @@ -101,17 +101,21 @@ actor PaywallRequestManager { activeTasks[requestHash] = task var paywall = try await task.value - paywall = await updatePaywall(paywall, for: request) + // The request that fetched the paywall keeps the id its response events were tracked with. + paywall = await updatePaywall(paywall, for: request, isNewPresentation: false) return paywall } private func updatePaywall( _ paywall: Paywall, - for request: PaywallRequest + for request: PaywallRequest, + isNewPresentation: Bool = true ) async -> Paywall { var paywall = paywall - paywall.presentationId = UUID().uuidString + if isNewPresentation { + paywall.presentationId = UUID().uuidString + } paywall.experiment = request.responseIdentifiers.experiment paywall.presentationSourceType = request.presentationSourceType if let featureGating = request.overrides.featureGatingBehavior { diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 015d019e32..3129b3db0c 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -872,7 +872,6 @@ public class PaywallViewController: UIViewController, LoadingDelegate { || isBeingPresented { return completion(false) } - completedPurchaseProduct = nil Superwall.shared.presentationItems.window?.makeKeyAndVisible() set( @@ -1622,6 +1621,9 @@ extension PaywallViewController { didCallDelegate = false paywall.closeReason = .none + // A purchase belongs to the presentation it was made in. It's cleared here rather than when + // the view disappears: a sheet swiped away disappears before its close is reported. + completedPurchaseProduct = nil Superwall.shared.dependencyContainer.delegateAdapter.willPresentPaywall(withInfo: info) webView.scrollView.contentOffset = CGPoint.zero @@ -1695,10 +1697,6 @@ extension PaywallViewController { if isSafariVCPresented { return } - // 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 } @@ -1726,11 +1724,15 @@ extension PaywallViewController { completedPurchaseProduct = product } - /// The result a dismissal reports: a decline after a completed purchase is a purchase. + /// The result a dismissal reports: a decline after a completed purchase is a purchase. A close + /// to show the next paywall stays as it is, so that paywall keeps the presentation's state. func resolvedDismissal( result: PaywallResult, closeReason: PaywallCloseReason ) -> (result: PaywallResult, closeReason: PaywallCloseReason) { + if closeReason == .forNextPaywall { + return (result, closeReason) + } if result == .declined, let product = completedPurchaseProduct { return (.purchased(product), .systemLogic) } diff --git a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift index 56f59c2a7b..93df113eaa 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/TeleportReturnCover.swift @@ -35,13 +35,16 @@ extension Notification.Name { /// have the old names, so each call falls back to them. @MainActor final class TeleportReturnCover: NSObject { + // Picks the function before calling it, so a page with both names only has one called. static let showWaitingScreenScript = """ - return await (window.app?.showTeleportWaitingScreen?.() - ?? window.app?.showCheckoutWaitingScreen?.()) ?? false + const app = window.app; + const show = app?.showTeleportWaitingScreen ?? app?.showCheckoutWaitingScreen; + return await show?.call(app) ?? false """ static let returnScript = """ - return await (window.app?.handleTeleportReturn?.({ reason }) - ?? window.app?.hideCheckoutWaitingScreen?.({ reason })) ?? false + const app = window.app; + const handleReturn = app?.handleTeleportReturn ?? app?.hideCheckoutWaitingScreen; + return await handleReturn?.call(app, { reason }) ?? false """ /// What the page hands over to check its teleport while the waiting screen is up. @@ -179,7 +182,6 @@ final class TeleportReturnCover: NSObject { func leave(drawingWaitingScreen: Bool, open: @escaping () -> Void) { guard drawingWaitingScreen, - #available(iOS 14.0, *), let view, let webView, let still = environment.makeStill(view) @@ -238,9 +240,15 @@ final class TeleportReturnCover: NSObject { } pendingOpen = nil pending.open() + let still = leavingStill Task { @MainActor [weak self, timing] in try? await Task.sleep(nanoseconds: timing.stillTimeout) - guard let self, environment.applicationState() == .active else { + // A later leave put up its own still, which its own timeout takes down. + guard + let self, + self.leavingStill === still, + environment.applicationState() == .active + else { return } self.removeLeavingStill() @@ -274,9 +282,10 @@ final class TeleportReturnCover: NSObject { return } switch returnCover.reason { - case .settled, - .returnLink: + case .returnLink: uncover() + case .settled: + uncoverAfterGrace() case .suspending: revealAfterGrace() } @@ -438,7 +447,7 @@ final class TeleportReturnCover: NSObject { returnCover.view.alpha = 1 let id = UUID() uncovering = id - if #available(iOS 14.0, *), let webView { + if let webView { webView.callAsyncJavaScript( Self.returnScript, arguments: ["reason": returnReason ?? ""], @@ -473,7 +482,7 @@ final class TeleportReturnCover: NSObject { private extension TeleportReturnCover { func tellPageOfReturn() { - guard #available(iOS 14.0, *), let webView else { + guard let webView else { return } webView.callAsyncJavaScript( @@ -486,6 +495,27 @@ private extension TeleportReturnCover { returnReason = nil } + /// Settled in the background: the page is told the user is back once a return link has had the + /// chance to arrive, so it hears why they came back when they came back through the link. + func uncoverAfterGrace() { + guard revealing == nil, uncovering == nil else { + return + } + let id = UUID() + revealing = id + Task { @MainActor [weak self, timing] in + try? await Task.sleep(nanoseconds: timing.revealGrace) + guard let self, self.revealing == id else { + return + } + self.revealing = nil + guard environment.applicationState() == .active else { + return + } + self.uncover() + } + } + /// Back by hand with the teleport still open: the waiting screen is still the right thing to /// show, so the cover fades off it, unless a return link turns up first. func revealAfterGrace() { @@ -574,19 +604,12 @@ enum TeleportStatusCheck { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(publicApiKey)", forHTTPHeaderField: "Authorization") request.httpBody = try? JSONSerialization.data(withJSONObject: body) - return await withCheckedContinuation { continuation in - URLSession.shared.dataTask(with: request) { data, response, _ in - guard - let data, - (response as? HTTPURLResponse)?.statusCode == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { - continuation.resume(returning: nil) - return - } - continuation.resume(returning: json) - } - .resume() + guard + let (data, response) = try? await URLSession.shared.data(for: request), + (response as? HTTPURLResponse)?.statusCode == 200 + else { + return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] } } diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 25a4924608..f9a173ff43 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -36,6 +36,9 @@ actor WebEntitlementRedeemer { private var hasActiveStripePoll = false private var lastCompletedStripePollResult: (contextId: String, outcome: StripePollOutcome)? private var awaitingCheckoutComplete = false + /// The checkout the paywall last reported complete, while it's being finished. An older + /// checkout's poll stops for it, so it doesn't wait behind a poll that can't redeem it. + private var completingCheckoutContextId: String? private var superwall: Superwall? typealias Factory = WebEntitlementFactory & OptionsFactory @@ -193,16 +196,9 @@ actor WebEntitlementRedeemer { // If there's already an active poll (e.g. from cold-launch init task), // wait for it to finish rather than skipping. if hasActiveStripePoll { - let waitStart = DispatchTime.now().uptimeNanoseconds - while hasActiveStripePoll { - if Task.isCancelled { - return false - } - let elapsed = DispatchTime.now().uptimeNanoseconds - waitStart - if elapsed >= stripePendingPollTimeoutNs { - return false - } - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + await waitForActiveStripePoll() + if hasActiveStripePoll || Task.isCancelled { + return false } if let completed = lastCompletedStripePollResult, completed.contextId == waitingContextId { @@ -247,13 +243,22 @@ actor WebEntitlementRedeemer { if isRedeemed(contextId) { let superwall = self.superwall ?? Superwall.shared await MainActor.run { - if superwall.paywallViewController?.presentedViewController == nil { - superwall.paywallViewController?.loadingState = .ready + // The success alert's close takes the spinner down once the purchase is finished. + let paywallViewController = superwall.paywallViewController + if !(paywallViewController?.presentedViewController is UIAlertController) { + paywallViewController?.loadingState = .ready } } return } + completingCheckoutContextId = contextId + defer { + if completingCheckoutContextId == contextId { + completingCheckoutContextId = nil + } + } + if let existingState = pendingStripeCheckoutState, existingState.checkoutContextId == contextId { savePendingStripeCheckoutState( @@ -907,8 +912,9 @@ actor WebEntitlementRedeemer { clearPendingStripeCheckoutState() return .noRedemptionFound } - // A newer checkout took over the pending state: this one stops so that one is polled. - if let state = pendingStripeCheckoutState, state.checkoutContextId != contextId { + // A newer checkout was reported complete: this one stops so that one is polled. One that + // was only started doesn't stop it, since this one may still be paid for. + if let completingCheckoutContextId, completingCheckoutContextId != contextId { return .noRedemptionFound } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift index c89c4f3474..1e8fba8757 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/TeleportReturnCoverTests.swift @@ -21,7 +21,7 @@ struct TeleportReturnCoverTests { /// This fixture's own center, so no other test's cover hears its notifications. let notificationCenter = NotificationCenter() - init(timing: TeleportReturnCover.Timing) { + init(timing: TeleportReturnCover.Timing, isSettled: Bool = false) { window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) view = UIView(frame: window.bounds) webView = WKWebView(frame: window.bounds) @@ -30,7 +30,7 @@ struct TeleportReturnCoverTests { var state: UIApplication.State = .active var environment = TeleportReturnCover.Environment() environment.applicationState = { state } - environment.isSettled = { _ in false } + environment.isSettled = { _ in isSettled } environment.makeStill = { _ in UIView() } environment.beginBackgroundTask = { _ in UIBackgroundTaskIdentifier(rawValue: 1) } environment.endBackgroundTask = { _ in } @@ -172,6 +172,100 @@ struct TeleportReturnCoverTests { #expect(calls == ["show", "return:purchased"]) } + @Test("A page with both names only has the teleport-named function called") + func pageWithBothNames() async throws { + let webView = try await loadedWebView() + _ = try await webView.callAsyncJavaScript( + """ + window.calls = []; + window.app = { + showTeleportWaitingScreen: () => { window.calls.push("show") }, + showCheckoutWaitingScreen: () => { window.calls.push("showCheckout") }, + handleTeleportReturn: () => { window.calls.push("return") }, + hideCheckoutWaitingScreen: () => { window.calls.push("hideCheckout") }, + }; + """, + contentWorld: .page + ) + + _ = try await webView.callAsyncJavaScript( + TeleportReturnCover.showWaitingScreenScript, + contentWorld: .page + ) + _ = try await webView.callAsyncJavaScript( + TeleportReturnCover.returnScript, + arguments: ["reason": ""], + contentWorld: .page + ) + let calls = try await webView.callAsyncJavaScript( + "return window.calls", + contentWorld: .page + ) as? [String] + + #expect(calls == ["show", "return"]) + } + + @Test("A still from an earlier leave timing out does not take down a later leave's still") + func testEarlierStillTimeoutKeepsLaterStill() async { + var timing = Self.fastTiming + timing.stillTimeout = 200_000_000 + let fixture = Fixture(timing: timing) + + fixture.cover.leave(drawingWaitingScreen: true) {} + await sleep(0.1) + fixture.cover.leave(drawingWaitingScreen: true) {} + // Past the first leave's timeout, before the second's. + await sleep(0.18) + #expect(fixture.view.subviews.count == 1) + + await sleep(0.2) + #expect(fixture.view.subviews.isEmpty) + } + + @Test("Back after settling, the page hears the reason of a return link that arrives late") + func testSettledCoverWaitsForLateReturnLink() async throws { + var timing = Self.fastTiming + timing.backgroundCheckLimit = 1 + let fixture = Fixture(timing: timing, isSettled: true) + fixture.webView.loadHTMLString("", baseURL: nil) + while try await fixture.webView.evaluateJavaScript( + "document.getElementById('loaded') !== null" + ) as? Bool != true { + try await Task.sleep(nanoseconds: 10_000_000) + } + _ = try await fixture.webView.callAsyncJavaScript( + """ + window.reasons = []; + window.app = { + handleTeleportReturn: async (options) => { window.reasons.push(options.reason); return true }, + }; + """, + contentWorld: .page + ) + + fixture.cover.startWatching(Self.watch) + fixture.cover.leave(drawingWaitingScreen: true) {} + await sleep(0.1) + fixture.set(.background) + post(UIApplication.didEnterBackgroundNotification, to: fixture) + await sleep(0.1) + #expect(fixture.cover.isCovering) + + // The app is active before the link is handed over. + fixture.set(.active) + post(UIApplication.didBecomeActiveNotification, to: fixture) + await sleep(0.02) + post(.superwallReturnLinkOpened, to: fixture, userInfo: ["reason": "purchased"]) + await sleep(0.3) + + let reasons = try await fixture.webView.callAsyncJavaScript( + "return window.reasons", + contentWorld: .page + ) as? [String] + #expect(reasons == ["purchased"]) + #expect(!fixture.cover.isCovering) + } + @Test("A page without the functions is answered with false") func pageWithoutFunctions() async throws { let webView = try await loadedWebView() diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 7c3842583b..006f0f9804 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -2329,6 +2329,49 @@ struct WebEntitlementRedeemerTests { #expect(state?.remainingForegroundAttempts == 5) } + @Test("An older checkout keeps polling when a newer one is only started") + func testOlderPoll_keepsPollingWhenNewerCheckoutOnlyStarts() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + stripePendingPollIntervalNs: 20_000_000, + stripePendingPollTimeoutNs: 5_000_000_000, + superwall: superwall + ) + + try? await Task.sleep(nanoseconds: 50_000_000) + mockNetwork.pollRedemptionResultCallCount = 0 + mockNetwork.pollRedemptionResultResponses = Array( + repeating: .success(RedeemResponse(results: [], customerInfo: .blank(), status: .pending)), + count: 15 + ) + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_old", productId: "prod_old") + let olderPoll = Task { + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + } + try? await Task.sleep(nanoseconds: 60_000_000) + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_new", productId: "prod_new") + let callsWhenNewerStarted = mockNetwork.pollRedemptionResultCallCount + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(mockNetwork.pollRedemptionResultCallCount > callsWhenNewerStarted) + #expect(mockNetwork.pollRedemptionResultRequest?.checkoutContextId == "ctx_old") + await olderPoll.value + } + @Test("Foreground polling consumes attempts and clears pending after 5 tries") func testStripeForegroundPolling_consumesAttempts() async { @@ -3269,11 +3312,19 @@ struct WebEntitlementRedeemerTests { let restored = paywallVc.resolvedDismissal(result: .restored, closeReason: .systemLogic) #expect(restored.result == .restored) + + // A close to show the next paywall keeps its reason, so that paywall keeps the state. + let forNextPaywall = paywallVc.resolvedDismissal( + result: .declined, + closeReason: .forNextPaywall + ) + #expect(forNextPaywall.result == .declined) + #expect(forNextPaywall.closeReason == .forNextPaywall) } @Test("A purchase does not outlive its presentation: a later close is a decline") @MainActor - func testCompletedPurchase_clearedWhenViewDisappears() { + func testCompletedPurchase_clearedWhenNextPresentationBegins() { let paywallVc = PaywallViewController( paywall: .stub(), deviceHelper: dependencyContainer.deviceHelper, @@ -3298,7 +3349,14 @@ struct WebEntitlementRedeemerTests { ) paywallVc.markPurchaseCompleted(StoreProduct.blank(productIdentifier: "stripe|price_1")) + + // A sheet swiped away disappears before its close is reported, so the purchase is still + // there for that close. paywallVc.viewDidDisappear(false) + let swiped = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) + #expect(swiped.result != .declined) + + paywallVc.viewWillAppear(false) let later = paywallVc.resolvedDismissal(result: .declined, closeReason: .manualClose) #expect(later.result == .declined) From e8f7eb58265edb800437d2d18727df5a555d6cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:25:38 +0200 Subject: [PATCH 13/13] fix: only clear the pending checkout a finished poll belongs to An older checkout's poll keeps running when a newer checkout starts. When it finished it cleared the pending state, which by then was the newer checkout's, so that one could no longer be recovered. Co-Authored-By: Claude Opus 5.5 --- .../Web/WebEntitlementRedeemer.swift | 32 ++++++++++----- .../Web/WebEntitlementRedeemerTests.swift | 40 +++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index f9a173ff43..d9505a987c 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -495,7 +495,8 @@ actor WebEntitlementRedeemer { type: RedeemType, superwall: Superwall, callbackMode: RedemptionCallbackMode, - completion: StripeCheckoutCompletion = .restore + completion: StripeCheckoutCompletion = .restore, + checkoutContextId: String? = nil ) async { storage.save(Date(), forType: LastWebEntitlementsFetchDate.self) @@ -531,7 +532,8 @@ actor WebEntitlementRedeemer { paywallEntitlementIds: paywallEntitlementIds, superwall: superwall, callbackMode: callbackMode, - completion: completion + completion: completion, + checkoutContextId: checkoutContextId ) } } @@ -628,7 +630,8 @@ actor WebEntitlementRedeemer { paywallEntitlementIds: Set, superwall: Superwall, callbackMode: RedemptionCallbackMode, - completion: StripeCheckoutCompletion + completion: StripeCheckoutCompletion, + checkoutContextId: String? ) async { guard let codeResult = response.results.first(where: { $0.code == code }) else { return } @@ -725,7 +728,7 @@ actor WebEntitlementRedeemer { onClose: { [weak self] in Task { await afterRedeem() - await self?.clearPendingStripeCheckoutState() + await self?.clearPendingStripeCheckoutState(for: checkoutContextId) } } ) @@ -737,14 +740,14 @@ actor WebEntitlementRedeemer { onClose: { [weak self] in Task { await afterRedeem() - await self?.clearPendingStripeCheckoutState() + await self?.clearPendingStripeCheckoutState(for: checkoutContextId) } } ) } } else { await afterRedeem() - clearPendingStripeCheckoutState() + clearPendingStripeCheckoutState(for: checkoutContextId) } } @@ -875,6 +878,16 @@ actor WebEntitlementRedeemer { storage.delete(PendingStripeCheckoutPollStorage.self) } + /// Clears the pending checkout only if it's this checkout's, so a poll that finishes an older + /// checkout leaves a newer one to be recovered. Nil clears whatever is pending. + private func clearPendingStripeCheckoutState(for checkoutContextId: String?) { + if let checkoutContextId, + pendingStripeCheckoutState?.checkoutContextId != checkoutContextId { + return + } + clearPendingStripeCheckoutState() + } + private func makePollRedemptionRequest( contextId: String ) -> PollRedemptionResultRequest { @@ -928,7 +941,8 @@ actor WebEntitlementRedeemer { type: .code(code), superwall: superwall, callbackMode: .pollFakeCompatibility, - completion: completion + completion: completion, + checkoutContextId: contextId ) finalOutcome = .redeemed return finalOutcome @@ -936,7 +950,7 @@ actor WebEntitlementRedeemer { switch response.status { case .failed: - clearPendingStripeCheckoutState() + clearPendingStripeCheckoutState(for: contextId) finalOutcome = .checkoutFailed return finalOutcome case .pending: @@ -948,7 +962,7 @@ actor WebEntitlementRedeemer { try? await Task.sleep(nanoseconds: stripePendingPollIntervalNs) continue case .complete: - clearPendingStripeCheckoutState() + clearPendingStripeCheckoutState(for: contextId) finalOutcome = .noRedemptionFound return finalOutcome case .none: diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 006f0f9804..1c11555e28 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -2372,6 +2372,46 @@ struct WebEntitlementRedeemerTests { await olderPoll.value } + @Test("An older checkout's poll finishing leaves a newer checkout's pending state") + func testOlderPoll_finishingKeepsNewerPendingState() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + stripePendingPollIntervalNs: 20_000_000, + stripePendingPollTimeoutNs: 5_000_000_000, + superwall: superwall + ) + + try? await Task.sleep(nanoseconds: 50_000_000) + mockNetwork.pollRedemptionResultResponses = + Array( + repeating: .success(RedeemResponse(results: [], customerInfo: .blank(), status: .pending)), + count: 5 + ) + [.success(RedeemResponse(results: [], customerInfo: .blank(), status: .failed))] + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_old", productId: "prod_old") + let olderPoll = Task { + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + } + try? await Task.sleep(nanoseconds: 40_000_000) + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_new", productId: "prod_new") + await olderPoll.value + + let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) + #expect(state?.checkoutContextId == "ctx_new") + } + @Test("Foreground polling consumes attempts and clears pending after 5 tries") func testStripeForegroundPolling_consumesAttempts() async {