From 5cf675ca1ab89cce34fc528ebf6956a7a28c7adf Mon Sep 17 00:00:00 2001 From: Brian Anglin Date: Thu, 26 Mar 2026 09:40:26 -0700 Subject: [PATCH 01/34] Fix redeem auth race during startup --- Sources/SuperwallKit/Superwall.swift | 18 +++++++++++++++--- .../Storage/StorageTests.swift | 7 ++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 5565306f96..5303c8b73b 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -376,13 +376,27 @@ public final class Superwall: NSObject, ObservableObject { super.init() } + static func makeDependencyContainer( + apiKey: String, + purchaseController: PurchaseController? = nil, + options: SuperwallOptions? = nil + ) -> DependencyContainer { + let dependencyContainer = DependencyContainer( + purchaseController: purchaseController, + options: options + ) + dependencyContainer.storage.configure(apiKey: apiKey) + return dependencyContainer + } + private convenience init( apiKey: String, purchaseController: PurchaseController? = nil, options: SuperwallOptions? = nil, completion: (() -> Void)? ) { - let dependencyContainer = DependencyContainer( + let dependencyContainer = Self.makeDependencyContainer( + apiKey: apiKey, purchaseController: purchaseController, options: options ) @@ -407,8 +421,6 @@ public final class Superwall: NSObject, ObservableObject { #endif } - dependencyContainer.storage.configure(apiKey: apiKey) - dependencyContainer.storage.recordAppInstall(trackPlacement: track) async let fetchConfig: () = await dependencyContainer.configManager.fetchConfiguration() diff --git a/Tests/SuperwallKitTests/Storage/StorageTests.swift b/Tests/SuperwallKitTests/Storage/StorageTests.swift index 24e7b25135..babd67e8ae 100644 --- a/Tests/SuperwallKitTests/Storage/StorageTests.swift +++ b/Tests/SuperwallKitTests/Storage/StorageTests.swift @@ -10,6 +10,12 @@ import XCTest @testable import SuperwallKit class StorageTests: XCTestCase { + func test_makeDependencyContainer_configuresApiKeySynchronously() { + let dependencyContainer = Superwall.makeDependencyContainer(apiKey: "pk_test_123") + + XCTAssertEqual(dependencyContainer.storage.apiKey, "pk_test_123") + } + func test_overwriteAssignments() { let dependencyContainer = DependencyContainer() let storage = Storage(factory: dependencyContainer) @@ -36,4 +42,3 @@ class StorageTests: XCTestCase { XCTAssertTrue(retrievedAssignments2.isEmpty) } } - From cbabbcd3726c3eefe79283d8be7db624327f8aae Mon Sep 17 00:00:00 2001 From: Sebastian Szturo Date: Thu, 9 Apr 2026 10:47:15 +0900 Subject: [PATCH 02/34] Expose abandoned product params --- .../TrackableSuperwallEvent.swift | 10 +++++ .../Internal Tracking/TrackTests.swift | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 97dbd5a3fa..e5873369f2 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -671,6 +671,16 @@ enum InternalSuperwallEvent { var params = paywallInfo.audienceFilterParams() if let product = product { params["abandoned_product_id"] = product.productIdentifier + let hasRicherProductAttributes = + !product.localizedPrice.isEmpty + || !product.localizedSubscriptionPeriod.isEmpty + || !product.period.isEmpty + + if hasRicherProductAttributes { + for (key, value) in product.attributes { + params["abandoned_product_\(key.camelCaseToSnakeCase())"] = value + } + } } return params default: diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift index 51decc4b4c..6e5c7b8eb4 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift @@ -1695,6 +1695,43 @@ struct TrackingTests { result.parameters.audienceFilterParams["presented_by_event_name"] as? String == paywallInfo.presentedByPlacementWithName) } + @Test func transaction_abandon() async { + let paywallInfo: PaywallInfo = .stub() + let productId = "abc" + let product = StoreProduct( + sk1Product: MockSkProduct(productIdentifier: productId), + entitlements: [.stub()] + ) + let dependencyContainer = DependencyContainer() + let skTransaction = MockSKPaymentTransaction(state: .purchased) + let transaction = await dependencyContainer.makeStoreTransaction(from: skTransaction) + let result = await Superwall.shared.track( + InternalSuperwallEvent.Transaction( + state: .abandon(product), + paywallInfo: paywallInfo, + product: product, + transaction: transaction, + source: .external, + isObserved: false, + storeKitVersion: .storeKit1 + ) + ) + + #expect(result.parameters.audienceFilterParams["$event_name"] as! String == "transaction_abandon") + #expect(result.parameters.audienceFilterParams["$product_period"] != nil) + #expect(result.parameters.audienceFilterParams["$product_period_months"] != nil) + + #expect( + result.parameters.audienceFilterParams["event_name"] as! String == "transaction_abandon") + #expect( + result.parameters.audienceFilterParams["abandoned_product_id"] as! String == productId) + #expect(result.parameters.audienceFilterParams["abandoned_product_identifier"] as! String == productId) + #expect(result.parameters.audienceFilterParams["abandoned_product_period"] != nil) + #expect(result.parameters.audienceFilterParams["abandoned_product_period_months"] != nil) + #expect(result.parameters.audienceFilterParams["abandoned_product_period_years"] != nil) + #expect(result.parameters.audienceFilterParams["abandoned_product_localized_period"] != nil) + } + @Test func transaction_fail() async { let paywallInfo: PaywallInfo = .stub() let productId = "abc" From 0deb490c0fa72ff7323f306854f7634603d63147 Mon Sep 17 00:00:00 2001 From: Sebastian Szturo Date: Thu, 9 Apr 2026 12:33:56 +0900 Subject: [PATCH 03/34] Tighten abandoned product filtering --- .../TrackableSuperwallEvent.swift | 3 +-- .../Internal Tracking/TrackTests.swift | 13 ++++++++----- .../Mocks/SKProductSubscriptionPeriodMock.swift | 17 ++++++++++++++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index e5873369f2..8084d9d2ee 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -672,8 +672,7 @@ enum InternalSuperwallEvent { if let product = product { params["abandoned_product_id"] = product.productIdentifier let hasRicherProductAttributes = - !product.localizedPrice.isEmpty - || !product.localizedSubscriptionPeriod.isEmpty + !product.localizedSubscriptionPeriod.isEmpty || !product.period.isEmpty if hasRicherProductAttributes { diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift index 6e5c7b8eb4..0deed330c7 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift @@ -1699,7 +1699,10 @@ struct TrackingTests { let paywallInfo: PaywallInfo = .stub() let productId = "abc" let product = StoreProduct( - sk1Product: MockSkProduct(productIdentifier: productId), + sk1Product: MockSkProduct( + subscriptionPeriod: SKProductSubscriptionPeriodMock(numberOfUnits: 1, unit: .month), + productIdentifier: productId + ), entitlements: [.stub()] ) let dependencyContainer = DependencyContainer() @@ -1726,10 +1729,10 @@ struct TrackingTests { #expect( result.parameters.audienceFilterParams["abandoned_product_id"] as! String == productId) #expect(result.parameters.audienceFilterParams["abandoned_product_identifier"] as! String == productId) - #expect(result.parameters.audienceFilterParams["abandoned_product_period"] != nil) - #expect(result.parameters.audienceFilterParams["abandoned_product_period_months"] != nil) - #expect(result.parameters.audienceFilterParams["abandoned_product_period_years"] != nil) - #expect(result.parameters.audienceFilterParams["abandoned_product_localized_period"] != nil) + #expect(result.parameters.audienceFilterParams["abandoned_product_period"] as? String == "month") + #expect(result.parameters.audienceFilterParams["abandoned_product_period_months"] as? String == "1") + #expect(result.parameters.audienceFilterParams["abandoned_product_period_years"] as? String == "0") + #expect((result.parameters.audienceFilterParams["abandoned_product_localized_period"] as? String)?.isEmpty == false) } @Test func transaction_fail() async { diff --git a/Tests/SuperwallKitTests/StoreKit/Mocks/SKProductSubscriptionPeriodMock.swift b/Tests/SuperwallKitTests/StoreKit/Mocks/SKProductSubscriptionPeriodMock.swift index dc6bcd25ff..767e56a370 100644 --- a/Tests/SuperwallKitTests/StoreKit/Mocks/SKProductSubscriptionPeriodMock.swift +++ b/Tests/SuperwallKitTests/StoreKit/Mocks/SKProductSubscriptionPeriodMock.swift @@ -9,7 +9,22 @@ import Foundation import StoreKit final class SKProductSubscriptionPeriodMock: SKProductSubscriptionPeriod { + private let internalNumberOfUnits: Int + private let internalUnit: SKProduct.PeriodUnit + override var numberOfUnits: Int { - return 1 + return internalNumberOfUnits + } + + override var unit: SKProduct.PeriodUnit { + return internalUnit + } + + init( + numberOfUnits: Int = 1, + unit: SKProduct.PeriodUnit = .month + ) { + self.internalNumberOfUnits = numberOfUnits + self.internalUnit = unit } } From ca12e2cf56cc1ee9b9adc890002fb6784fabf876 Mon Sep 17 00:00:00 2001 From: Mathis Detourbet Date: Thu, 16 Apr 2026 13:12:41 +0200 Subject: [PATCH 04/34] Sanitize email user attribute before sending to checkout API The checkout API rejects `context.identity.email` unless it is a valid email address or null. Apps that set a placeholder like `"none"` when the user has no email silently break the Stripe checkout flow because the server returns a validation error and no checkout session is created. Introduce an `Email` domain primitive with a failable initializer that validates against the same regex the API enforces. When merging user attributes, the SDK now parses the `email` value through `Email` and drops it (sends null) when invalid, with a warning log. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SuperwallKit/Identity/Email.swift | 29 ++++ .../Identity/UserAttributes.swift | 30 +++- .../Identity/EmailTests.swift | 131 ++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 Sources/SuperwallKit/Identity/Email.swift create mode 100644 Tests/SuperwallKitTests/Identity/EmailTests.swift diff --git a/Sources/SuperwallKit/Identity/Email.swift b/Sources/SuperwallKit/Identity/Email.swift new file mode 100644 index 0000000000..2e915fbef6 --- /dev/null +++ b/Sources/SuperwallKit/Identity/Email.swift @@ -0,0 +1,29 @@ +// +// Email.swift +// SuperwallKit +// + +import Foundation + +/// A validated email address. +/// +/// The failable initializer rejects any string that does not match the +/// pattern expected by the checkout API (`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`). +/// Holding an `Email` instance proves the value was validated — downstream +/// code never needs to re-check. +struct Email: Equatable, Sendable { + let rawValue: String + + private static let regex = try! NSRegularExpression( + pattern: #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"# + ) + + /// Returns `nil` when `rawValue` is not a syntactically valid email address. + init?(_ rawValue: String) { + let range = NSRange(rawValue.startIndex..., in: rawValue) + guard Self.regex.firstMatch(in: rawValue, range: range) != nil else { + return nil + } + self.rawValue = rawValue + } +} diff --git a/Sources/SuperwallKit/Identity/UserAttributes.swift b/Sources/SuperwallKit/Identity/UserAttributes.swift index f3e390c4ac..6ff8d8e2a3 100644 --- a/Sources/SuperwallKit/Identity/UserAttributes.swift +++ b/Sources/SuperwallKit/Identity/UserAttributes.swift @@ -94,11 +94,39 @@ extension Superwall { continue } if JSONSerialization.isValidJSONObject([key: value]) { - customAttributes[key] = value + customAttributes[key] = Self.sanitizeAttribute(key: key, value: value) } } } dependencyContainer.identityManager.mergeUserAttributes(customAttributes) } + + /// Validates attribute values that have server-side schema constraints. + /// + /// The checkout API rejects `context.identity.email` unless it is either a + /// valid email address or `null`. Apps that set a placeholder like `"none"` + /// would silently break the Stripe checkout flow, so the SDK parses the + /// value through ``Email`` and drops it when invalid. + static func sanitizeAttribute(key: String, value: Any?) -> Any? { + guard let stringValue = value as? String else { + return value + } + + switch key { + case "email": + guard let email = Email(stringValue) else { + Logger.debug( + logLevel: .warn, + scope: .identityManager, + message: "Invalid email user attribute \"\(stringValue)\" — sending null to server" + ) + return nil + } + return email.rawValue + + default: + return value + } + } } diff --git a/Tests/SuperwallKitTests/Identity/EmailTests.swift b/Tests/SuperwallKitTests/Identity/EmailTests.swift new file mode 100644 index 0000000000..35c9444614 --- /dev/null +++ b/Tests/SuperwallKitTests/Identity/EmailTests.swift @@ -0,0 +1,131 @@ +// +// EmailTests.swift +// SuperwallKit +// + +import Testing +@testable import SuperwallKit + +@Suite("Email") +struct EmailTests { + + // MARK: - Valid emails + + @Test("accepts a simple email address") + func `simple email`() { + #expect(Email("user@example.com") != nil) + } + + @Test("accepts email with dots in local part") + func `dotted local part`() { + #expect(Email("first.last@domain.co") != nil) + } + + @Test("accepts email with plus tag") + func `plus tag`() { + #expect(Email("user+tag@example.com") != nil) + } + + @Test("accepts email with subdomain") + func `subdomain`() { + #expect(Email("user@mail.example.co.uk") != nil) + } + + @Test("accepts email with numbers") + func `numbers`() { + #expect(Email("user123@domain456.com") != nil) + } + + @Test("accepts email with hyphen in domain") + func `hyphenated domain`() { + #expect(Email("user@my-domain.com") != nil) + } + + @Test("accepts email with underscore and percent") + func `underscore and percent`() { + #expect(Email("user_%name@domain.org") != nil) + } + + @Test("preserves the raw value on success") + func `preserves raw value`() { + let address = "hello@world.com" + let email = Email(address) + #expect(email?.rawValue == address) + } + + // MARK: - Invalid emails + + @Test( + "rejects strings that are not valid email addresses", + arguments: [ + "none", + "", + "userexample.com", + "user@", + "@example.com", + "user@domain", + "user@domain.a", + "user @example.com", + "not an email", + "null", + "N/A", + ] + ) + func `rejects invalid value`(value: String) { + #expect(Email(value) == nil) + } + + // MARK: - Equatable + + @Test("two emails with the same address are equal") + func `equal emails`() { + #expect(Email("a@b.com") == Email("a@b.com")) + } + + @Test("two emails with different addresses are not equal") + func `different emails`() { + #expect(Email("a@b.com") != Email("x@y.com")) + } +} + +// MARK: - sanitizeAttribute + +@Suite("Superwall.sanitizeAttribute") +struct SanitizeAttributeTests { + + @Test("passes a valid email through unchanged") + func `valid email passes through`() { + let result = Superwall.sanitizeAttribute(key: "email", value: "user@example.com") + #expect(result as? String == "user@example.com") + } + + @Test("replaces the placeholder 'none' with nil for the email key") + func `none placeholder becomes nil`() { + let result = Superwall.sanitizeAttribute(key: "email", value: "none") + #expect(result == nil) + } + + @Test("replaces an empty string with nil for the email key") + func `empty string becomes nil`() { + let result = Superwall.sanitizeAttribute(key: "email", value: "") + #expect(result == nil) + } + + @Test("does not sanitize non-email keys") + func `non email key untouched`() { + let result = Superwall.sanitizeAttribute(key: "name", value: "none") + #expect(result as? String == "none") + } + + @Test("does not sanitize non-string values on the email key") + func `non string value untouched`() { + let result = Superwall.sanitizeAttribute(key: "email", value: 42) + #expect(result as? Int == 42) + } + + @Test("passes nil through unchanged") + func `nil value passes through`() { + let result = Superwall.sanitizeAttribute(key: "email", value: nil) + #expect(result == nil) + } +} From 9c743b7d6d18f6eca4f0d776e4fcc49e11099d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:07:16 +0100 Subject: [PATCH 05/34] Update StorageTests.swift --- Tests/SuperwallKitTests/Storage/StorageTests.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Tests/SuperwallKitTests/Storage/StorageTests.swift b/Tests/SuperwallKitTests/Storage/StorageTests.swift index 9aa9fdd549..7fbe696b03 100644 --- a/Tests/SuperwallKitTests/Storage/StorageTests.swift +++ b/Tests/SuperwallKitTests/Storage/StorageTests.swift @@ -6,17 +6,19 @@ // // swiftlint:disable all -import XCTest import Testing @testable import SuperwallKit -class StorageTests: XCTestCase { +@Suite +struct StorageTests { + @Test func test_makeDependencyContainer_configuresApiKeySynchronously() { let dependencyContainer = Superwall.makeDependencyContainer(apiKey: "pk_test_123") - XCTAssertEqual(dependencyContainer.storage.apiKey, "pk_test_123") + #expect(dependencyContainer.storage.apiKey == "pk_test_123") } + @Test func test_overwriteAssignments() { let dependencyContainer = DependencyContainer() let storage = Storage(factory: dependencyContainer) @@ -35,12 +37,12 @@ class StorageTests: XCTestCase { storage.overwriteAssignments(assignments) let retrievedAssignments = storage.getAssignments() - XCTAssertEqual(retrievedAssignments.first, assignments.first) + #expect(retrievedAssignments.first == assignments.first) storage.reset() let retrievedAssignments2 = storage.getAssignments() - XCTAssertTrue(retrievedAssignments2.isEmpty) + #expect(retrievedAssignments2.isEmpty) } } From 49577fea14e947c2c15d20b744b655346c39aed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:42:41 +0100 Subject: [PATCH 06/34] Move apiKey configuration into DependencyContainer init Configures storage with apiKey inside DependencyContainer.init rather than via a separate Superwall.makeDependencyContainer factory. Prevents code paths from reading storage.apiKey between init and configure. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Dependencies/DependencyContainer.swift | 2 ++ Sources/SuperwallKit/Superwall.swift | 15 +-------------- .../SuperwallKitTests/Storage/StorageTests.swift | 4 ++-- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 125a768743..80ee40a9d3 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -48,11 +48,13 @@ final class DependencyContainer { let paywallArchiveManager = PaywallArchiveManager() init( + apiKey: String = "", purchaseController controller: PurchaseController? = nil, options: SuperwallOptions? = nil ) { delegateAdapter = SuperwallDelegateAdapter() storage = Storage(factory: self) + storage.configure(apiKey: apiKey) entitlementsInfo = EntitlementsInfo( storage: storage, delegateAdapter: delegateAdapter diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 59ee4398c5..e75668226d 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -413,26 +413,13 @@ public final class Superwall: NSObject, ObservableObject { super.init() } - static func makeDependencyContainer( - apiKey: String, - purchaseController: PurchaseController? = nil, - options: SuperwallOptions? = nil - ) -> DependencyContainer { - let dependencyContainer = DependencyContainer( - purchaseController: purchaseController, - options: options - ) - dependencyContainer.storage.configure(apiKey: apiKey) - return dependencyContainer - } - private convenience init( apiKey: String, purchaseController: PurchaseController? = nil, options: SuperwallOptions? = nil, completion: (() -> Void)? ) { - let dependencyContainer = Self.makeDependencyContainer( + let dependencyContainer = DependencyContainer( apiKey: apiKey, purchaseController: purchaseController, options: options diff --git a/Tests/SuperwallKitTests/Storage/StorageTests.swift b/Tests/SuperwallKitTests/Storage/StorageTests.swift index 7fbe696b03..f7fd4516e5 100644 --- a/Tests/SuperwallKitTests/Storage/StorageTests.swift +++ b/Tests/SuperwallKitTests/Storage/StorageTests.swift @@ -12,8 +12,8 @@ import Testing @Suite struct StorageTests { @Test - func test_makeDependencyContainer_configuresApiKeySynchronously() { - let dependencyContainer = Superwall.makeDependencyContainer(apiKey: "pk_test_123") + func test_dependencyContainerInit_configuresApiKeySynchronously() { + let dependencyContainer = DependencyContainer(apiKey: "pk_test_123") #expect(dependencyContainer.storage.apiKey == "pk_test_123") } From df851890bd8d2b8c46932d86bbaa7a5667e882ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:25:16 +0100 Subject: [PATCH 07/34] Update Package.resolved --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8dcbeeb950..d4f4ee039c 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "155ea739f45f54189ca83ee9088b373c1415d98b", - "version": "5.64.0" + "revision": "bd63241b2258ea519020eb32a349db44fb44b119", + "version": "5.68.0" } }, { From 3a769861c8d7b0855e4def5826f0663468b2e1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:17:59 +0100 Subject: [PATCH 08/34] Wire custom callbacks through getPaywall Adds an optional `onCustomCallback` parameter to `getPaywall(...)` so paywalls embedded by the developer can handle custom webview callbacks. Previously this was only wired up through `register()`, which presents in its own UIWindow. The handler is stored on `PaywallViewControllerDelegateAdapter` and registered with `CustomCallbackRegistry` from `PaywallViewController` on init / when the delegate is reassigned, and unregistered on deinit. Bumps version to 4.15.1. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 + .../Dependencies/DependencyContainer.swift | 3 +- .../Graveyard/SuperwallGraveyard.swift | 3 +- Sources/SuperwallKit/Misc/Constants.swift | 2 +- .../Get Paywall/PublicGetPaywall.swift | 16 +- ...PaywallViewControllerDelegateAdapter.swift | 7 +- .../PaywallViewController.swift | 30 +++- SuperwallKit.podspec | 2 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../TrackingLogicTests.swift | 3 +- .../CustomCallbackRegistryTests.swift | 167 ++++++++++++++++++ .../PresentPaywallOperatorTests.swift | 6 +- .../View Controller/SurveyManagerTests.swift | 30 ++-- .../Web/WebEntitlementRedeemerTests.swift | 24 ++- 14 files changed, 272 insertions(+), 31 deletions(-) create mode 100644 Tests/SuperwallKitTests/Paywall/Presentation/CustomCallbackRegistryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c37715008a..5aba3a5713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.15.1 + +### Enhancements + +- Adds an `onCustomCallback` parameter to `getPaywall(forPlacement:params:paywallOverrides:delegate:onCustomCallback:)` so paywalls retrieved for embedding can handle custom webview callbacks. Previously, custom callbacks were only supported via `register()`, which presents in its own `UIWindow`. + ## 4.15.0 ### Enhancements diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 80ee40a9d3..8b61915fc5 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -314,7 +314,8 @@ extension DependencyContainer: ViewControllerFactory { webView: webView, webEntitlementRedeemer: webEntitlementRedeemer, cache: cache, - paywallArchiveManager: paywallArchiveManager + paywallArchiveManager: paywallArchiveManager, + customCallbackRegistry: customCallbackRegistry ) webView.delegate = paywallViewController diff --git a/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift b/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift index e4025d3002..7607474530 100644 --- a/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift +++ b/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift @@ -107,7 +107,8 @@ extension Superwall { ), webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: dependencyContainer.makeCache(), - paywallArchiveManager: dependencyContainer.paywallArchiveManager + paywallArchiveManager: dependencyContainer.paywallArchiveManager, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) } diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 19f2179977..03b9d70246 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.15.0 +4.15.1 """ diff --git a/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift b/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift index e33af687be..5feb088e55 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/PublicGetPaywall.swift @@ -24,6 +24,9 @@ extension Superwall { /// be dropped. /// - paywallOverrides: An optional ``PaywallOverrides`` object whose parameters override the paywall defaults. Use this to override products and presentation style. Defaults to `nil`. /// - delegate: A delegate responsible for handling user interactions with the retrieved ``PaywallViewController``. + /// - onCustomCallback: An optional async block invoked when the paywall webview triggers a custom callback. + /// Return a ``CustomCallbackResult`` to report success or failure back to the webview. Defaults to `nil`, in which + /// case the webview receives a failure result for any custom callbacks. /// - completion: A completion block accepting an optional ``PaywallViewController``, an optional /// ``PaywallSkippedReason`` and an optional `Error`. If the ``PaywallViewController`` couldn't be retrieved /// because its presentation should be skipped, the ``PaywallSkippedReason`` will be non-`nil`. Any errors @@ -35,6 +38,7 @@ extension Superwall { params: [String: Any]? = nil, paywallOverrides: PaywallOverrides? = nil, delegate: PaywallViewControllerDelegate, + onCustomCallback: ((CustomCallback) async -> CustomCallbackResult)? = nil, completion: @escaping (PaywallViewController?, PaywallSkippedReason?, Error?) -> Void ) { Task { @MainActor in @@ -43,7 +47,8 @@ extension Superwall { forPlacement: placement, params: params, paywallOverrides: paywallOverrides, - delegate: delegate + delegate: delegate, + onCustomCallback: onCustomCallback ) completion(paywallViewController, nil, nil) } catch let reason as PaywallSkippedReason { @@ -68,6 +73,9 @@ extension Superwall { /// be dropped. /// - paywallOverrides: An optional ``PaywallOverrides`` object whose parameters override the paywall defaults. Use this to override products and presentation style. Defaults to `nil`. /// - delegate: A delegate responsible for handling user interactions with the retrieved ``PaywallViewController``. + /// - onCustomCallback: An optional async block invoked when the paywall webview triggers a custom callback. + /// Return a ``CustomCallbackResult`` to report success or failure back to the webview. Defaults to `nil`, in which + /// case the webview receives a failure result for any custom callbacks. /// /// - Returns: A ``PaywallViewController`` object. /// - Throws: An `Error` explaining why it couldn't get the view controller. If the ``PaywallViewController`` couldn't be retrieved @@ -80,7 +88,8 @@ extension Superwall { forPlacement placement: String, params: [String: Any]? = nil, paywallOverrides: PaywallOverrides? = nil, - delegate: PaywallViewControllerDelegate + delegate: PaywallViewControllerDelegate, + onCustomCallback: ((CustomCallback) async -> CustomCallbackResult)? = nil ) async throws -> PaywallViewController { return try await internallyGetPaywall( forPlacement: placement, @@ -88,7 +97,8 @@ extension Superwall { paywallOverrides: paywallOverrides, delegate: .init( swiftDelegate: delegate, - objcDelegate: nil + objcDelegate: nil, + onCustomCallback: onCustomCallback ) ) } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift b/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift index 1a995ead05..e79d4e8a6a 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Delegates/PaywallViewControllerDelegateAdapter.swift @@ -12,16 +12,21 @@ final class PaywallViewControllerDelegateAdapter { weak var swiftDelegate: PaywallViewControllerDelegate? weak var objcDelegate: PaywallViewControllerDelegateObjc? + /// An optional handler invoked when the paywall webview triggers a custom callback. + let onCustomCallback: ((CustomCallback) async -> CustomCallbackResult)? + var hasObjcDelegate: Bool { return objcDelegate != nil } init( swiftDelegate: PaywallViewControllerDelegate?, - objcDelegate: PaywallViewControllerDelegateObjc? + objcDelegate: PaywallViewControllerDelegateObjc?, + onCustomCallback: ((CustomCallback) async -> CustomCallbackResult)? = nil ) { self.swiftDelegate = swiftDelegate self.objcDelegate = objcDelegate + self.onCustomCallback = onCustomCallback } @MainActor diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 32cff16239..22584ace41 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -71,7 +71,11 @@ public class PaywallViewController: UIViewController, LoadingDelegate { } } - var delegate: PaywallViewControllerDelegateAdapter? + var delegate: PaywallViewControllerDelegateAdapter? { + didSet { + syncCustomCallbackRegistration() + } + } typealias Factory = TriggerFactory & RestoreAccessFactory @@ -215,6 +219,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { private unowned let storage: Storage private unowned let deviceHelper: DeviceHelper private unowned let webEntitlementRedeemer: WebEntitlementRedeemer + private unowned let customCallbackRegistry: CustomCallbackRegistry private weak var cache: PaywallViewControllerCache? private weak var paywallArchiveManager: PaywallArchiveManager? @@ -231,7 +236,8 @@ public class PaywallViewController: UIViewController, LoadingDelegate { webView: SWWebView, webEntitlementRedeemer: WebEntitlementRedeemer, cache: PaywallViewControllerCache?, - paywallArchiveManager: PaywallArchiveManager? + paywallArchiveManager: PaywallArchiveManager?, + customCallbackRegistry: CustomCallbackRegistry ) { self.cache = cache self.paywallArchiveManager = paywallArchiveManager @@ -248,15 +254,34 @@ public class PaywallViewController: UIViewController, LoadingDelegate { self.webView = webView self.introOfferTokenManager = IntroOfferTokenManager(network: network) self.webEntitlementRedeemer = webEntitlementRedeemer + self.customCallbackRegistry = customCallbackRegistry presentationStyle = paywall.presentation.style super.init(nibName: nil, bundle: nil) + syncCustomCallbackRegistration() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + /// Registers or unregisters the delegate's custom callback handler with + /// ``CustomCallbackRegistry`` so the paywall webview can invoke it. + /// + /// `register()` manages registration around its own present/dismiss lifecycle, so this + /// path is the one that wires up handlers supplied via + /// ``Superwall/getPaywall(forPlacement:params:paywallOverrides:delegate:onCustomCallback:)``. + private func syncCustomCallbackRegistration() { + if let handler = delegate?.onCustomCallback { + customCallbackRegistry.register( + paywallIdentifier: paywall.identifier, + handler: handler + ) + } else { + customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier) + } + } + public override func viewDidLoad() { super.viewDidLoad() configureUI() @@ -266,6 +291,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { deinit { introOfferTokenManager.stopObservingAppLifecycle() + customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier) } private func configureUI() { diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index cd0272c647..8b1f2be8b1 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.15.0" + s.version = "4.15.1" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index a5819ce27e..e53dacd309 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -370,6 +370,7 @@ B15607185B9E4229C6C4F240 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */; }; B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9C9132109020FA03D1D5C7 /* MockExternalPurchaseControllerFactory.swift */; }; B2AB1E9283FDE2D544C8BCA8 /* MockReceiptData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */; }; + B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */; }; B2B5684F46FB49AB9E3C1BE0 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E47DA89C9F4FBD7FA038F5 /* Cache.swift */; }; B3E6E82C0240EE6048360C9B /* RawWebMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C39015ED9E8F5D9F0F78F1E /* RawWebMessageHandler.swift */; }; B456ED8E41AD35A0EF5C295F /* ProductVariable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8730F0D05BFDFA12AA6309 /* ProductVariable.swift */; }; @@ -874,6 +875,7 @@ 8BAEECE2DBFEB8817E6C36DA /* PaywallViewControllerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerCache.swift; sourceTree = ""; }; 8BDA9D233EACAB5090B0657D /* StorePresentationObjectsOperatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePresentationObjectsOperatorTests.swift; sourceTree = ""; }; 8CA2E0A3532CE2F0AFD6F20B /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCallbackRegistryTests.swift; sourceTree = ""; }; 8D45DC0981EE9BBE3BF56C48 /* PurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseController.swift; sourceTree = ""; }; 8D5F8BE7E93645C0FCA49E4A /* PopupTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTransition.swift; sourceTree = ""; }; 8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandler.swift; sourceTree = ""; }; @@ -2076,6 +2078,7 @@ 75743C9AAFCAB9D91984F19C /* Presentation */ = { isa = PBXGroup; children = ( + 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */, A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */, 43F99E26CAFD72F228189A4D /* Audience Logic */, B2C3E282003472D3326477C4 /* Internal Presentation */, @@ -3189,6 +3192,7 @@ A1621A749D8F05959A486ACE /* CoreDataManagerMock.swift in Sources */, C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */, ABC17AE96AD396607E3CAB17 /* CoreDataStackMock.swift in Sources */, + B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */, 2517FC60F3A7288C5FE34A73 /* CustomProductTests.swift in Sources */, 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift index cc5c470d27..c06021c6ae 100644 --- a/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackingLogicTests.swift @@ -320,7 +320,8 @@ struct TrackingLogicTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) let outcome = await TrackingLogic.canTriggerPaywall( diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/CustomCallbackRegistryTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/CustomCallbackRegistryTests.swift new file mode 100644 index 0000000000..3be0a2de97 --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/Presentation/CustomCallbackRegistryTests.swift @@ -0,0 +1,167 @@ +// +// CustomCallbackRegistryTests.swift +// +// +// Created by Yusuf Tör on 22/04/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite +@MainActor +struct CustomCallbackGetPaywallTests { + private func makePaywallVc( + paywall: Paywall, + delegate: PaywallViewControllerDelegateAdapter?, + in dependencyContainer: DependencyContainer + ) -> PaywallViewControllerMock { + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = SWWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + return PaywallViewControllerMock( + paywall: paywall, + delegate: delegate, + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: webView, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: nil, + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + } + + @Test + func customCallbackHandlerIsRegisteredWhenAdapterHasHandler() async { + let dependencyContainer = DependencyContainer() + let paywall = Paywall.stub() + .setting(\.identifier, to: "paywall_register_test") + + let adapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: { callback in + return .success(data: ["echo": callback.name]) + } + ) + + let paywallVc = makePaywallVc(paywall: paywall, delegate: adapter, in: dependencyContainer) + + let registered = dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) + #expect(registered != nil) + + let result = await registered?(CustomCallback(name: "ping", variables: nil)) + #expect(result?.status == .success) + #expect(result?.data?["echo"] as? String == "ping") + + _ = paywallVc + } + + @Test + func noHandlerRegisteredWhenAdapterHasNone() async { + let dependencyContainer = DependencyContainer() + let paywall = Paywall.stub() + .setting(\.identifier, to: "paywall_no_handler_test") + + let adapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: nil + ) + + let paywallVc = makePaywallVc(paywall: paywall, delegate: adapter, in: dependencyContainer) + + let registered = dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) + #expect(registered == nil) + + _ = paywallVc + } + + @Test + func reassigningDelegateUpdatesRegistration() async { + let dependencyContainer = DependencyContainer() + let paywall = Paywall.stub() + .setting(\.identifier, to: "paywall_reassign_test") + + let firstAdapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: { _ in .success(data: ["origin": "first"]) } + ) + + let paywallVc = makePaywallVc( + paywall: paywall, + delegate: firstAdapter, + in: dependencyContainer + ) + + let firstResult = await dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + )?(CustomCallback(name: "anything", variables: nil)) + #expect(firstResult?.data?["origin"] as? String == "first") + + let secondAdapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: { _ in .success(data: ["origin": "second"]) } + ) + paywallVc.delegate = secondAdapter + + let secondResult = await dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + )?(CustomCallback(name: "anything", variables: nil)) + #expect(secondResult?.data?["origin"] as? String == "second") + + paywallVc.delegate = nil + + let cleared = dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) + #expect(cleared == nil) + } + + @Test + func handlerIsUnregisteredWhenViewControllerDeinits() async { + let dependencyContainer = DependencyContainer() + let paywall = Paywall.stub() + .setting(\.identifier, to: "paywall_deinit_test") + + weak var weakVc: PaywallViewControllerMock? + autoreleasepool { + let adapter = PaywallViewControllerDelegateAdapter( + swiftDelegate: nil, + objcDelegate: nil, + onCustomCallback: { _ in .success(data: nil) } + ) + let pvc = makePaywallVc(paywall: paywall, delegate: adapter, in: dependencyContainer) + weakVc = pvc + #expect(dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) != nil) + _ = pvc + } + + #expect(weakVc == nil) + let registered = dependencyContainer.customCallbackRegistry.getHandler( + paywallIdentifier: paywall.identifier + ) + #expect(registered == nil) + } +} diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift index d4de259c75..5061dee3f8 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift @@ -55,7 +55,8 @@ final class PresentPaywallOperatorTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) webView.delegate = paywallVc @@ -124,7 +125,8 @@ final class PresentPaywallOperatorTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) paywallVc.shouldPresent = false webView.delegate = paywallVc diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift index 0842631700..ed844e5740 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift @@ -40,7 +40,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -87,7 +88,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -134,7 +136,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation(expectedCount: 0) { completed in @@ -182,7 +185,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -229,7 +233,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -276,7 +281,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -329,7 +335,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -385,7 +392,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation { completed in @@ -437,7 +445,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation(expectedCount: 0) { completed in @@ -495,7 +504,8 @@ struct SurveyManagerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: nil, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) await confirmation(expectedCount: 0) { completed in diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 0a9ffcf43b..a2c6374299 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -315,7 +315,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -430,7 +431,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -949,7 +951,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1122,7 +1125,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1304,7 +1308,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1488,7 +1493,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1666,7 +1672,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" @@ -1830,7 +1837,8 @@ struct WebEntitlementRedeemerTests { webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, cache: cache, - paywallArchiveManager: nil + paywallArchiveManager: nil, + customCallbackRegistry: dependencyContainer.customCallbackRegistry ) cache.save(paywallVc, forKey: "key") cache.activePaywallVcKey = "key" From a8dce74b7900527dc106bbe62212d276992e520f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:55:26 +0100 Subject: [PATCH 09/34] Shorten 4.15.1 CHANGELOG entry Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aba3a5713..436dfa7c29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements -- Adds an `onCustomCallback` parameter to `getPaywall(forPlacement:params:paywallOverrides:delegate:onCustomCallback:)` so paywalls retrieved for embedding can handle custom webview callbacks. Previously, custom callbacks were only supported via `register()`, which presents in its own `UIWindow`. +- Adds an `onCustomCallback` parameter to `getPaywall`. ## 4.15.0 From 85539a7828583b0714294bd08e50c2366a63f532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:57:42 +0100 Subject: [PATCH 10/34] Track callback registration ownership per VC Only unregister the custom callback handler when this PVC was the one that registered it. Prevents a getPaywall-flow VC from clearing a register-flow handler that happens to share the same paywall identifier. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../View Controller/PaywallViewController.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 22584ace41..9353a7b2bd 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -271,14 +271,18 @@ public class PaywallViewController: UIViewController, LoadingDelegate { /// `register()` manages registration around its own present/dismiss lifecycle, so this /// path is the one that wires up handlers supplied via /// ``Superwall/getPaywall(forPlacement:params:paywallOverrides:delegate:onCustomCallback:)``. + private var hasRegisteredCallback = false + private func syncCustomCallbackRegistration() { if let handler = delegate?.onCustomCallback { customCallbackRegistry.register( paywallIdentifier: paywall.identifier, handler: handler ) - } else { + hasRegisteredCallback = true + } else if hasRegisteredCallback { customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier) + hasRegisteredCallback = false } } @@ -291,7 +295,9 @@ public class PaywallViewController: UIViewController, LoadingDelegate { deinit { introOfferTokenManager.stopObservingAppLifecycle() - customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier) + if hasRegisteredCallback { + customCallbackRegistry.unregister(paywallIdentifier: paywall.identifier) + } } private func configureUI() { From 2968b23bd46adbe1f2967f0cdb46da86d6bbb892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:22:31 +0100 Subject: [PATCH 11/34] Update packages --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d4f4ee039c..c9e34050a5 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "bd63241b2258ea519020eb32a349db44fb44b119", - "version": "5.68.0" + "revision": "a06accc1543fcedc3094d4b5b9a40b84e2213e8e", + "version": "5.69.0" } }, { diff --git a/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3345860464..251dbbdc99 100644 --- a/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/superwall/Superscript-iOS", "state": { "branch": null, - "revision": "ce546d7ad70b5ce5f66ea0caffefb0d97be49f34", - "version": "1.0.12" + "revision": "711866edcb62dbd237c24ed3e5fa39fad0db639f", + "version": "1.0.13" } } ] From dc89136837bcc3be5c362829748eef2b2323c9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:16:02 +0100 Subject: [PATCH 12/34] Accept asset catalog entries in localResources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalises SuperwallOptions.localResources from `[String: URL]` to `[String: AssetResource]`. URL conforms to AssetResource so existing call sites are unaffected. New `CatalogAsset(name:bundle:)` registers a Data Set entry from an .xcassets, resolved at load time via NSDataAsset — the iOS equivalent of Android's R.raw.* resource IDs. ObjC keeps a URL-only shim under the same `localResources` name. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../Config/Options/AssetResource.swift | 54 +++++++++++++++++++ .../Config/Options/SuperwallOptions.swift | 32 ++++++++--- .../SWLocalResourcesViewController.swift | 36 +++++++++++-- .../Web View/LocalFileSchemeHandler.swift | 43 +++++++++++++-- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ .../LocalFileSchemeHandlerTests.swift | 35 ++++++++++++ 7 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 Sources/SuperwallKit/Config/Options/AssetResource.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 436dfa7c29..67ce2c5dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements - Adds an `onCustomCallback` parameter to `getPaywall`. +- `SuperwallOptions.localResources` now accepts `AssetResource` values, so paywall assets can be registered from an asset catalog (`.xcassets` Data Sets) via `CatalogAsset(name:bundle:)` in addition to file URLs. `URL` conforms to `AssetResource`, so existing call sites are unaffected. ## 4.15.0 diff --git a/Sources/SuperwallKit/Config/Options/AssetResource.swift b/Sources/SuperwallKit/Config/Options/AssetResource.swift new file mode 100644 index 0000000000..2a348cb640 --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/AssetResource.swift @@ -0,0 +1,54 @@ +// +// AssetResource.swift +// SuperwallKit +// +// Created by Yusuf Tör on 24/04/2026. +// + +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +/// A type that can be registered against ``SuperwallOptions/localResources`` and +/// served to the paywall webview via the `swlocal://` URL scheme. +/// +/// `URL` conforms out of the box, so existing call sites registering file URLs +/// keep working. To register an asset from an `.xcassets` Data Set (the iOS +/// equivalent of Android's `R.raw.*` resource IDs), use ``CatalogAsset``. +/// +/// ```swift +/// options.localResources = [ +/// "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")!, +/// "hero-video": CatalogAsset(name: "HeroVideo") +/// ] +/// ``` +public protocol AssetResource {} + +extension URL: AssetResource {} + +/// An entry in an asset catalog (`.xcassets`) stored as a Data Set. +/// +/// Resolved at load time via `NSDataAsset(name:bundle:)`, so the raw bytes are +/// preserved without re-encoding. Use this when you want to ship paywall assets +/// inside your `.xcassets` instead of as loose files in the bundle. +/// +/// - Note: Add the asset to your `.xcassets` as a **Data Set** (not an Image Set) +/// so `NSDataAsset` can read it and the webview receives the bytes verbatim. +public struct CatalogAsset: AssetResource { + /// The name of the data asset as it appears in the asset catalog. + public let name: String + + /// The bundle that contains the asset catalog. + public let bundle: Bundle + + /// Creates a reference to a Data Set entry in an asset catalog. + /// + /// - Parameters: + /// - name: The name of the data asset as it appears in the asset catalog. + /// - bundle: The bundle that contains the asset catalog. Defaults to `.main`. + public init(name: String, bundle: Bundle = .main) { + self.name = name + self.bundle = bundle + } +} diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c86f925762..f0efad1909 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -4,6 +4,7 @@ // // Created by Yusuf Tör on 11/07/2022. // +// swiftlint:disable file_length import Foundation @@ -11,18 +12,22 @@ import Foundation /// /// Pass an instance of this class to /// ``Superwall/configure(apiKey:purchaseController:options:completion:)-52tke``. +// swiftlint:disable type_body_length @objc(SWKSuperwallOptions) @objcMembers public final class SuperwallOptions: NSObject, Encodable { /// Configures the appearance and behaviour of paywalls. public var paywalls = PaywallOptions() - /// A mapping of local resource IDs to local file URLs. + /// A mapping of local resource IDs to ``AssetResource`` values. /// - /// Use this to serve paywall assets (images, videos, Lottie animations) from local files - /// instead of fetching them over the network. When a paywall references a `localResourceId`, - /// the SDK will look up the corresponding URL in this dictionary and serve the file via the - /// `swlocal://` URL scheme. + /// Use this to serve paywall assets (images, videos, Lottie animations) from the app + /// bundle or an asset catalog instead of fetching them over the network. When a paywall + /// references a `localResourceId`, the SDK looks up the corresponding entry here and + /// serves it via the `swlocal://` URL scheme. + /// + /// `URL` conforms to ``AssetResource`` so file-URL call sites keep working. Register + /// entries from an `.xcassets` Data Set with ``CatalogAsset``. /// /// Set this before calling ``Superwall/configure(apiKey:purchaseController:options:completion:)-52tke`` /// to ensure resources are available before any paywall can trigger (e.g. on `app_launch`). @@ -30,12 +35,25 @@ public final class SuperwallOptions: NSObject, Encodable { /// ```swift /// let options = SuperwallOptions() /// options.localResources = [ - /// "hero-video": Bundle.main.url(forResource: "onboarding", withExtension: "mp4")!, + /// "hero-video": CatalogAsset(name: "OnboardingHero"), /// "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")! /// ] /// Superwall.configure(apiKey: "your-api-key", options: options) /// ``` - public var localResources: [String: URL] = [:] + @nonobjc public var localResources: [String: AssetResource] = [:] + + /// Objective-C bridge for ``localResources``. Exposed to ObjC under the name + /// `localResources` and limited to `URL` values (asset-catalog entries are Swift-only). + @available(swift, obsoleted: 1.0) + @objc(localResources) + public var localResourcesObjC: [String: URL] { + get { + return localResources.compactMapValues { $0 as? URL } + } + set { + localResources = newValue.mapValues { $0 as AssetResource } + } + } /// Controls when the SDK enters test mode. @objc(SWKTestModeBehavior) diff --git a/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift b/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift index 54753bd4c9..b1412d4583 100644 --- a/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift +++ b/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift @@ -7,7 +7,7 @@ import UIKit import AVFoundation final class SWLocalResourcesViewController: UICollectionViewController { - private var resources: [(id: String, url: URL)] = [] + private var resources: [(id: String, resource: AssetResource)] = [] init() { let layout = UICollectionViewFlowLayout() @@ -53,7 +53,7 @@ final class SWLocalResourcesViewController: UICollectionViewController { resources = Superwall.shared.options.localResources .sorted { $0.key < $1.key } - .map { (id: $0.key, url: $0.value) } + .map { (id: $0.key, resource: $0.value) } } @objc private func doneTapped() { @@ -86,7 +86,7 @@ final class SWLocalResourcesViewController: UICollectionViewController { // swiftlint:disable:next force_cast ) as! LocalResourceCell let resource = resources[indexPath.item] - cell.configure(id: resource.id, url: resource.url) + cell.configure(id: resource.id, resource: resource.resource) return cell } } @@ -231,7 +231,18 @@ private final class LocalResourceCell: UICollectionViewCell { spinner.stopAnimating() } - func configure(id: String, url: URL) { + func configure(id: String, resource: AssetResource) { + if let url = resource as? URL { + configureURL(id: id, url: url) + } else if let catalog = resource as? CatalogAsset { + configureCatalogAsset(id: id, catalog: catalog) + } else { + idLabel.text = id + showErrorText("Unsupported resource type") + } + } + + private func configureURL(id: String, url: URL) { let ext = url.pathExtension.lowercased() idLabel.text = ext.isEmpty ? id : "\(id).\(ext)" spinner.startAnimating() @@ -249,6 +260,23 @@ private final class LocalResourceCell: UICollectionViewCell { } } + private func configureCatalogAsset(id: String, catalog: CatalogAsset) { + idLabel.text = "\(id) (asset: \(catalog.name))" + spinner.startAnimating() + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let asset = NSDataAsset(name: catalog.name, bundle: catalog.bundle) + let image = asset.flatMap { UIImage(data: $0.data) } + DispatchQueue.main.async { + self?.spinner.stopAnimating() + if let image = image { + self?.imageView.image = image + } else if asset == nil { + self?.showErrorText("Asset not found") + } + } + } + } + private func loadImage(from url: URL) { DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift index 6a179e4326..cda5e6a732 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift @@ -7,6 +7,12 @@ import Foundation import WebKit +#if canImport(UIKit) +import UIKit +#endif +#if canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif /// Handles custom URL scheme requests for serving local files to the paywall webview. /// @@ -83,7 +89,8 @@ final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler { // MARK: - File Loading - /// Loads a file from `SuperwallOptions.localResources` based on the URL host (the localResourceId). + /// Loads a file from `SuperwallOptions.localResources` based on the URL host + /// (the localResourceId). /// - Parameter url: The swlocal:// URL where the host is the localResourceId /// - Returns: Tuple of file data and MIME type func loadFile(from url: URL) throws -> (Data, String) { @@ -91,16 +98,44 @@ final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler { throw FileError.invalidURL } - guard let localURL = Superwall.shared.options.localResources[host] else { + guard let resource = Superwall.shared.options.localResources[host] else { throw FileError.fileNotFound(host) } + return try load(resource: resource, key: host) + } + + /// Resolves an ``AssetResource`` to its data and MIME type. + private func load(resource: AssetResource, key: String) throws -> (Data, String) { + if let localURL = resource as? URL { + return try loadFile(at: localURL) + } + if let catalog = resource as? CatalogAsset { + guard let asset = NSDataAsset(name: catalog.name, bundle: catalog.bundle) else { + throw FileError.fileNotFound("\(key) (data asset \(catalog.name))") + } + return (asset.data, mimeType(forUTI: asset.typeIdentifier)) + } + throw FileError.fileNotFound(key) + } + + private func loadFile(at localURL: URL) throws -> (Data, String) { guard let data = try? Data(contentsOf: localURL) else { throw FileError.unableToReadFile(localURL.path) } + return (data, mimeType(for: localURL.pathExtension)) + } - let mimeType = self.mimeType(for: localURL.pathExtension) - return (data, mimeType) + /// Maps a UTI (e.g. `public.png`, `public.mpeg-4`) to a MIME type. + /// Falls back to `application/octet-stream` if the UTI can't be resolved. + private func mimeType(forUTI uti: String) -> String { + if #available(iOS 14.0, *) { + if let type = UTType(uti), + let mime = type.preferredMIMEType { + return mime + } + } + return "application/octet-stream" } // MARK: - MIME Type Detection diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index e53dacd309..5c6b2ba1b3 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -443,6 +443,7 @@ CE43399599386456DCCD1FDC /* FakeLocationAuthorizationStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3529B422EB2D09B69265B591 /* FakeLocationAuthorizationStatusTests.swift */; }; CE5307A037FCFD8CB8380EB5 /* GetPaywallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3781CF21200CD2333F6779A /* GetPaywallManager.swift */; }; CE821667CB6676EA02510FE9 /* TrackingManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78C5C57C3C92444EBAC2E38 /* TrackingManagerProxy.swift */; }; + CE85C4CC2D3E98738F2C37E7 /* AssetResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23307BBFD80385233DDD4C43 /* AssetResource.swift */; }; CEF9BBFFE0D64A011A59FB4C /* TestModeManagerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17DB6AB272712E9350966E4 /* TestModeManagerFactory.swift */; }; CF2064883604B915C8768FC5 /* ASIdManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF989B3D90DC3D88FACC4D45 /* ASIdManagerProxy.swift */; }; CF3683E2AD703237EC0CE22E /* PaywallProducts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */; }; @@ -647,6 +648,7 @@ 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewURLConfig.swift; sourceTree = ""; }; 22919EFD263425D38E7D9D38 /* WebEntitlementRedeemer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebEntitlementRedeemer.swift; sourceTree = ""; }; 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerWrapper.swift; sourceTree = ""; }; + 23307BBFD80385233DDD4C43 /* AssetResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResource.swift; sourceTree = ""; }; 236900A8A8F95CE92E612458 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = ""; }; 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeLocationManager.swift; sourceTree = ""; }; 23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandlerTests.swift; sourceTree = ""; }; @@ -2906,6 +2908,7 @@ E7D208EAB24A633EB74B1E31 /* Options */ = { isa = PBXGroup; children = ( + 23307BBFD80385233DDD4C43 /* AssetResource.swift */, B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */, CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */, ); @@ -3323,6 +3326,7 @@ 9DC74748B0DC9DA519E70FE6 /* Array+Capability.swift in Sources */, F8E799A3A83A2758D6EAA385 /* Array+Guarded.swift in Sources */, B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */, + CE85C4CC2D3E98738F2C37E7 /* AssetResource.swift in Sources */, 2428529A6B2B6E873DEC22E8 /* Assignment.swift in Sources */, 75083E470EB6E25E01F4F28B /* AsyncSequence+Extract.swift in Sources */, 69D24C6E0411E512FECF7258 /* Attribution.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift index 19470ec00a..a53cae1572 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift @@ -98,6 +98,41 @@ struct LocalFileSchemeHandlerTests { Superwall.shared.options.localResources = [:] } + // MARK: - AssetResource Tests + + @Test("loadFile throws fileNotFound when CatalogAsset name is missing from bundle") + func loadFileMissingCatalogAsset() { + let handler = LocalFileSchemeHandler() + Superwall.shared.options.localResources = [ + "hero-video": CatalogAsset(name: "ThisAssetDoesNotExist", bundle: .main) + ] + let url = URL(string: "swlocal://hero-video")! + + #expect(throws: LocalFileSchemeHandler.FileError.fileNotFound("hero-video (data asset ThisAssetDoesNotExist)")) { + try handler.loadFile(from: url) + } + + Superwall.shared.options.localResources = [:] + } + + @Test("URL conforms to AssetResource and registers via the same dictionary") + func loadFileURLConformance() throws { + let handler = LocalFileSchemeHandler() + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent("asset-resource-url.png") + try Data("png-bytes".utf8).write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + Superwall.shared.options.localResources = ["hero-image": tempFile] + let url = URL(string: "swlocal://hero-image")! + + let (data, mimeType) = try handler.loadFile(from: url) + #expect(data == Data("png-bytes".utf8)) + #expect(mimeType == "image/png") + + Superwall.shared.options.localResources = [:] + } + @Test("loadFile detects correct mime types from file extension") func loadFileMimeTypes() throws { let handler = LocalFileSchemeHandler() From 61d38f2223f4caee8d992682af5af9e2f3cf7aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:25:37 +0100 Subject: [PATCH 13/34] Back-fill CatalogAsset MIME type on iOS 13 `UTType.preferredMIMEType` is iOS 14+. On iOS 13, fall back to `UTTypeCopyPreferredTagWithClass` from MobileCoreServices so catalog assets are served with a real MIME type (an `` or `